mirror of
https://github.com/immich-app/immich.git
synced 2026-01-31 09:14:47 -08:00
Compare commits
11 Commits
v2.5.0
...
feat/html-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194671f23e | ||
|
|
41e2ed3754 | ||
|
|
1319ad373f | ||
|
|
97df9fd53f | ||
|
|
4707821451 | ||
|
|
20c4d375b1 | ||
|
|
46d2238431 | ||
|
|
f7291c3a0b | ||
|
|
b5a3334e30 | ||
|
|
53718f01bb | ||
|
|
b51e0f1007 |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -269,6 +269,8 @@ jobs:
|
||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
|
||||
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
# Only upload to TestFlight on main branch
|
||||
|
||||
7
.github/workflows/cli.yml
vendored
7
.github/workflows/cli.yml
vendored
@@ -24,10 +24,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@da177fa133657503ddb7503f8ba53dccefec5da1 # create-workflow-token-action-v1.0.0
|
||||
@@ -57,10 +58,8 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: pnpm publish --no-git-checks
|
||||
- run: pnpm publish --provenance --no-git-checks
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
docker:
|
||||
name: Docker
|
||||
|
||||
6
.github/workflows/sdk.yml
vendored
6
.github/workflows/sdk.yml
vendored
@@ -12,6 +12,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
@@ -42,6 +44,4 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Publish
|
||||
run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm publish --provenance --no-git-checks
|
||||
|
||||
2
.github/workflows/weblate-lock.yml
vendored
2
.github/workflows/weblate-lock.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
i18n:
|
||||
- modified: 'i18n/!(en)**\.json'
|
||||
- modified: 'i18n/!(en|package)**\.json'
|
||||
skip-force-logic: 'true'
|
||||
|
||||
enforce-lock:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.106",
|
||||
"version": "2.5.1",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
32
docs/static/archived-versions.json
vendored
32
docs/static/archived-versions.json
vendored
@@ -1,40 +1,20 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.5.0",
|
||||
"url": "https://docs.v2.5.0.archive.immich.app"
|
||||
"label": "v2.5.1",
|
||||
"url": "https://docs.v2.5.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.1",
|
||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.0",
|
||||
"url": "https://docs.v2.4.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.3.1",
|
||||
"url": "https://docs.v2.3.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.3.0",
|
||||
"url": "https://docs.v2.3.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.3",
|
||||
"url": "https://docs.v2.2.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.2",
|
||||
"url": "https://docs.v2.2.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.1",
|
||||
"url": "https://docs.v2.2.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.2.0",
|
||||
"url": "https://docs.v2.2.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.1.0",
|
||||
"url": "https://docs.v2.1.0.archive.immich.app"
|
||||
@@ -43,18 +23,10 @@
|
||||
"label": "v2.0.1",
|
||||
"url": "https://docs.v2.0.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.0.0",
|
||||
"url": "https://docs.v2.0.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.144.1",
|
||||
"url": "https://docs.v1.144.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.144.0",
|
||||
"url": "https://docs.v1.144.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.143.1",
|
||||
"url": "https://docs.v1.143.1.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -572,6 +572,9 @@
|
||||
"asset_list_layout_sub_title": "Rozložení",
|
||||
"asset_list_settings_subtitle": "Nastavení rozložení mřížky fotografií",
|
||||
"asset_list_settings_title": "Mřížka fotografií",
|
||||
"asset_not_found_on_device_android": "Položka nebyla nalezena na zařízení",
|
||||
"asset_not_found_on_device_ios": "Položka nebyla nalezena na zařízení. Pokud používáte iCloud, položka může být nepřístupná kvůli poškozenému souboru uloženému na iCloudu",
|
||||
"asset_not_found_on_icloud": "Položka nebyla nalezena na iCloudu. Položka může být nepřístupná kvůli poškozenému souboru uloženému na iCloudu",
|
||||
"asset_offline": "Offline položka",
|
||||
"asset_offline_description": "Toto externí položka se již na disku nenachází. Obraťte se na správce Immich a požádejte o pomoc.",
|
||||
"asset_restored_successfully": "Položka úspěšně obnovena",
|
||||
@@ -2295,6 +2298,7 @@
|
||||
"upload_details": "Detaily nahrávání",
|
||||
"upload_dialog_info": "Chcete zálohovat vybrané položky na server?",
|
||||
"upload_dialog_title": "Nahrát položku",
|
||||
"upload_error_with_count": "Chyba při nahrávání {count, plural, one {# položky} other {# položek}}",
|
||||
"upload_errors": "Nahrávání bylo dokončeno s {count, plural, one {# chybou} other {# chybami}}, obnovte stránku pro zobrazení nových položek.",
|
||||
"upload_finished": "Nahrávání dokončeno",
|
||||
"upload_progress": "Zbývá {remaining, number} - Zpracováno {processed, number}/{total, number}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "1.0.0",
|
||||
"version": "2.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
|
||||
@@ -763,7 +763,7 @@
|
||||
"cleanup_found_assets": "Znaleziono {count} zasobów z przesłaną kopią zapasową",
|
||||
"cleanup_found_assets_with_size": "Znaleziono {count} zasobów z kopią zapasową ({size})",
|
||||
"cleanup_icloud_shared_albums_excluded": "Udostępniane albumy iCloud są wyłączone ze skanowania",
|
||||
"cleanup_no_assets_found": "Nie znaleziono żadnych zasobów spełniających podane kryteria. Zwolnij Miejsce może usuwać jedynie zasoby, które posiadają kopię zapasową na serwerze.",
|
||||
"cleanup_no_assets_found": "Nie znaleziono żadnych zasobów spełniających podane kryteria. Zwolnij Miejsce może usuwać jedynie zasoby, które posiadają kopię zapasową na serwerze",
|
||||
"cleanup_preview_title": "Zasoby do usunięcia ({count})",
|
||||
"cleanup_step3_description": "Wyszukaj zasoby z kopią zapasową, zgodne z Twoimi ustawieniami.",
|
||||
"cleanup_step4_summary": "{count} zasoby (utworzone przed {date}) zostaną usunięte z tego urządzenia. Zdjęcia będą nadal dostępne w aplikacji Immich.",
|
||||
|
||||
@@ -2298,6 +2298,7 @@
|
||||
"upload_details": "Подробности загрузки",
|
||||
"upload_dialog_info": "Хотите загрузить выбранные объекты на сервер?",
|
||||
"upload_dialog_title": "Загрузить объект",
|
||||
"upload_error_with_count": "Ошибка при загрузке {count, plural, one {# объекта} other {# объектов}}",
|
||||
"upload_errors": "Загрузка завершена с {count, plural, one {# ошибкой} other {# ошибками}}, обновите страницу, чтобы увидеть новые загруженные объекты.",
|
||||
"upload_finished": "Загрузка завершена",
|
||||
"upload_progress": "Осталось {remaining, number} - Обработано {processed, number}/{total, number}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#! /usr/bin/env node
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
|
||||
const asVersion = (item) => {
|
||||
const { label, url } = item;
|
||||
const [major, minor, patch] = label.substring(1).split('.').map(Number);
|
||||
return { major, minor, patch, label, url };
|
||||
};
|
||||
|
||||
const nextVersion = process.argv[2];
|
||||
if (!nextVersion) {
|
||||
console.log('Usage: archive-version.js <version>');
|
||||
@@ -8,10 +14,32 @@ if (!nextVersion) {
|
||||
}
|
||||
|
||||
const filename = './docs/static/archived-versions.json';
|
||||
const oldVersions = JSON.parse(readFileSync(filename));
|
||||
const newVersions = [
|
||||
{ label: `v${nextVersion}`, url: `https://docs.v${nextVersion}.archive.immich.app` },
|
||||
...oldVersions,
|
||||
];
|
||||
let versions = JSON.parse(readFileSync(filename));
|
||||
const newVersion = {
|
||||
label: `v${nextVersion}`,
|
||||
url: `https://docs.v${nextVersion}.archive.immich.app`,
|
||||
};
|
||||
|
||||
writeFileSync(filename, JSON.stringify(newVersions, null, 2) + '\n');
|
||||
let lastVersion = asVersion(newVersion);
|
||||
for (const item of versions) {
|
||||
const version = asVersion(item);
|
||||
// only keep the latest patch version for each minor release
|
||||
if (
|
||||
lastVersion.major === version.major &&
|
||||
lastVersion.minor === version.minor &&
|
||||
lastVersion.patch >= version.patch
|
||||
) {
|
||||
versions = versions.filter((item) => item.label !== version.label);
|
||||
console.log(
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
lastVersion = version;
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
filename,
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
|
||||
);
|
||||
|
||||
@@ -61,26 +61,23 @@ fi
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
jq --arg version "$NEXT_SERVER" '.version = $version' server/package.json > server/package.json.tmp && mv server/package.json.tmp server/package.json
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix i18n
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix open-api/typescript-sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
pnpm install --frozen-lockfile --prefix server
|
||||
pnpm --prefix server run build
|
||||
|
||||
( cd ./open-api && bash ./bin/generate-open-api.sh )
|
||||
|
||||
jq --arg version "$NEXT_SERVER" '.version = $version' open-api/typescript-sdk/package.json > open-api/typescript-sdk/package.json.tmp && mv open-api/typescript-sdk/package.json.tmp open-api/typescript-sdk/package.json
|
||||
|
||||
# TODO use $SERVER_PUMP once we pass 2.2.x
|
||||
CURRENT_CLI_VERSION=$(jq -r '.version' cli/package.json)
|
||||
CLI_PATCH_VERSION=$(echo "$CURRENT_CLI_VERSION" | awk -F. '{print $1"."$2"."($3+1)}')
|
||||
jq --arg version "$CLI_PATCH_VERSION" '.version = $version' cli/package.json > cli/package.json.tmp && mv cli/package.json.tmp cli/package.json
|
||||
pnpm install --frozen-lockfile --prefix cli
|
||||
|
||||
jq --arg version "$NEXT_SERVER" '.version = $version' web/package.json > web/package.json.tmp && mv web/package.json.tmp web/package.json
|
||||
pnpm install --frozen-lockfile --prefix web
|
||||
|
||||
jq --arg version "$NEXT_SERVER" '.version = $version' e2e/package.json > e2e/package.json.tmp && mv e2e/package.json.tmp e2e/package.json
|
||||
pnpm install --frozen-lockfile --prefix e2e
|
||||
uvx --from=toml-cli toml set --toml-path=machine-learning/pyproject.toml project.version "$NEXT_SERVER"
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
@@ -92,6 +89,5 @@ sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.in
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3031,
|
||||
"android.injected.version.name" => "2.5.0",
|
||||
"android.injected.version.code" => 3032,
|
||||
"android.injected.version.name" => "2.5.1",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.5.0</string>
|
||||
<string>2.5.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -193,7 +193,13 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
|
||||
},
|
||||
from14To15: (m, v15) async {
|
||||
await m.addColumn(v15.trashedLocalAssetEntity, v15.trashedLocalAssetEntity.source);
|
||||
await m.alterTable(
|
||||
TableMigration(
|
||||
v15.trashedLocalAssetEntity,
|
||||
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
|
||||
newColumns: [v15.trashedLocalAssetEntity.source],
|
||||
),
|
||||
);
|
||||
},
|
||||
from15To16: (m, v16) async {
|
||||
// Add i_cloud_id to local and remote asset tables
|
||||
|
||||
@@ -91,6 +91,18 @@ class ImmichUIShowcasePage extends StatelessWidget {
|
||||
children: [ImmichTextInput(label: "Title", hintText: "Enter a title")],
|
||||
),
|
||||
),
|
||||
const _ComponentTitle("HtmlText"),
|
||||
ImmichHtmlText(
|
||||
'This is an <b>example</b> of <test-link>HTML text</test-link> with <b>bold</b> and <link>links</link>.',
|
||||
linkHandlers: {
|
||||
'link': () {
|
||||
context.showSnackBar(const SnackBar(content: Text('Link tapped!')));
|
||||
},
|
||||
'test-link': () {
|
||||
context.showSnackBar(const SnackBar(content: Text('Test-link tapped!')));
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
|
||||
const FreeUpSpaceSettings({super.key});
|
||||
@@ -29,6 +30,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WakelockPlus.enable();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeAlbumDefaults();
|
||||
});
|
||||
@@ -168,6 +170,12 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
|
||||
context.pushRoute(CleanupPreviewRoute(assets: assets));
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
super.dispose();
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(cleanupProvider);
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.5.0
|
||||
- API version: 2.5.1
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
12
mobile/packages/ui/.gitignore
vendored
Normal file
12
mobile/packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
|
||||
# Platform-specific files are not needed as this is a Flutter UI package
|
||||
android/
|
||||
ios/
|
||||
|
||||
# Test cache and generated files
|
||||
.dart_tool/
|
||||
.packages
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
@@ -1,5 +1,6 @@
|
||||
export 'src/components/close_button.dart';
|
||||
export 'src/components/form.dart';
|
||||
export 'src/components/html_text.dart';
|
||||
export 'src/components/icon_button.dart';
|
||||
export 'src/components/password_input.dart';
|
||||
export 'src/components/text_button.dart';
|
||||
|
||||
189
mobile/packages/ui/lib/src/components/html_text.dart
Normal file
189
mobile/packages/ui/lib/src/components/html_text.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
enum _HtmlTagType {
|
||||
bold,
|
||||
link,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
class _HtmlTag {
|
||||
final _HtmlTagType type;
|
||||
final String tagName;
|
||||
|
||||
const _HtmlTag._({required this.type, required this.tagName});
|
||||
|
||||
static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported');
|
||||
|
||||
static _HtmlTag? fromString(dom.Node node) {
|
||||
final tagName = (node is dom.Element) ? node.localName : null;
|
||||
if (tagName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = tagName.toLowerCase();
|
||||
return switch (tag) {
|
||||
'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag),
|
||||
// Convert <a> back to 'link' for handler lookup
|
||||
'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'),
|
||||
_ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag),
|
||||
_ => _HtmlTag.unsupported,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that renders text with optional HTML-style formatting.
|
||||
///
|
||||
/// Supports the following tags:
|
||||
/// - `<b>` or `<strong>` for bold text
|
||||
/// - `<link>` or any tag ending with `-link` for tappable links
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// ImmichHtmlText(
|
||||
/// 'Refer to <link>docs</link> and <other-link>other</other-link>',
|
||||
/// linkHandlers: {
|
||||
/// 'link': () => launchUrl(docsUrl),
|
||||
/// 'other-link': () => launchUrl(otherUrl),
|
||||
/// },
|
||||
/// )
|
||||
/// ```
|
||||
class ImmichHtmlText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle? style;
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final bool? softWrap;
|
||||
final Map<String, VoidCallback>? linkHandlers;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const ImmichHtmlText(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.style,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.softWrap,
|
||||
this.linkHandlers,
|
||||
this.linkStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImmichHtmlText> createState() => _ImmichHtmlTextState();
|
||||
}
|
||||
|
||||
class _ImmichHtmlTextState extends State<ImmichHtmlText> {
|
||||
final _recognizers = <GestureRecognizer>[];
|
||||
dom.DocumentFragment _document = dom.DocumentFragment();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ImmichHtmlText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.text != widget.text) {
|
||||
_document = html_parser.parseFragment(_preprocessHtml(widget.text));
|
||||
}
|
||||
}
|
||||
|
||||
/// `<link>` tags are preprocessed to `<a>` tags because `<link>` is a
|
||||
/// void element in HTML5 and cannot have children. The linkHandlers still use
|
||||
/// 'link' as the key.
|
||||
String _preprocessHtml(String html) {
|
||||
return html
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)>(.*?)</\1>', caseSensitive: false),
|
||||
(match) => '<a>${match.group(2)}</a>',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
RegExp(r'<(link)\s*/>', caseSensitive: false),
|
||||
(match) => '<a></a>',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<InlineSpan> _buildSpans() {
|
||||
_disposeRecognizers();
|
||||
|
||||
return _document.nodes.expand((node) => _buildNode(node, null, null)).toList();
|
||||
}
|
||||
|
||||
Iterable<InlineSpan> _buildNode(
|
||||
dom.Node node,
|
||||
TextStyle? style,
|
||||
_HtmlTag? parentTag,
|
||||
) sync* {
|
||||
if (node is dom.Text) {
|
||||
if (node.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
GestureRecognizer? recognizer;
|
||||
if (parentTag?.type == _HtmlTagType.link) {
|
||||
final handler = widget.linkHandlers?[parentTag?.tagName];
|
||||
if (handler != null) {
|
||||
recognizer = TapGestureRecognizer()..onTap = handler;
|
||||
_recognizers.add(recognizer);
|
||||
}
|
||||
}
|
||||
|
||||
yield TextSpan(text: node.text, style: style, recognizer: recognizer);
|
||||
} else if (node is dom.Element) {
|
||||
final htmlTag = _HtmlTag.fromString(node);
|
||||
final tagStyle = _styleForTag(htmlTag);
|
||||
final mergedStyle = style?.merge(tagStyle) ?? tagStyle;
|
||||
final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag;
|
||||
|
||||
for (final child in node.nodes) {
|
||||
yield* _buildNode(child, mergedStyle, newParentTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle? _styleForTag(_HtmlTag? tag) {
|
||||
if (tag == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (tag.type) {
|
||||
_HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold),
|
||||
_HtmlTagType.link => widget.linkStyle ??
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
_HtmlTagType.unsupported => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: _buildSpans()),
|
||||
textAlign: widget.textAlign,
|
||||
overflow: widget.overflow,
|
||||
maxLines: widget.maxLines,
|
||||
softWrap: widget.softWrap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -9,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -17,11 +41,72 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -34,15 +119,71 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -51,5 +192,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
sdks:
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
||||
@@ -7,6 +7,11 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
html: ^0.15.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
266
mobile/packages/ui/test/html_test.dart
Normal file
266
mobile/packages/ui/test/html_test.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_ui/src/components/html_text.dart';
|
||||
|
||||
import 'test_utils.dart';
|
||||
|
||||
/// Text.rich creates a nested structure: root -> wrapper -> actual children
|
||||
List<InlineSpan> _getContentSpans(WidgetTester tester) {
|
||||
final richText = tester.widget<RichText>(find.byType(RichText));
|
||||
final root = richText.text as TextSpan;
|
||||
|
||||
if (root.children?.isNotEmpty ?? false) {
|
||||
final wrapper = root.children!.first;
|
||||
if (wrapper is TextSpan && wrapper.children != null) {
|
||||
return wrapper.children!;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
TextSpan _findSpan(List<InlineSpan> spans, String text) {
|
||||
return spans.firstWhere(
|
||||
(span) => span is TextSpan && span.text == text,
|
||||
orElse: () => throw StateError('No span found with text: "$text"'),
|
||||
) as TextSpan;
|
||||
}
|
||||
|
||||
String _concatenateText(List<InlineSpan> spans) {
|
||||
return spans.whereType<TextSpan>().map((s) => s.text ?? '').join();
|
||||
}
|
||||
|
||||
void _triggerTap(TextSpan span) {
|
||||
final recognizer = span.recognizer;
|
||||
if (recognizer is TapGestureRecognizer) {
|
||||
recognizer.onTap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ImmichHtmlText', () {
|
||||
testWidgets('renders plain text without HTML tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is plain text'),
|
||||
);
|
||||
|
||||
expect(find.text('This is plain text'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles mixed content with bold and links', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'This is an <b>example</b> of <b><link>HTML text</link></b> with <b>bold</b>.',
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final exampleSpan = _findSpan(spans, 'example');
|
||||
expect(exampleSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final linkSpan = _findSpan(spans, 'HTML text');
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
|
||||
expect(_concatenateText(spans), 'This is an example of HTML text with bold.');
|
||||
});
|
||||
|
||||
testWidgets('applies text style properties', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText(
|
||||
'Test text',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.purple,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
|
||||
final text = tester.widget<Text>(find.byType(Text));
|
||||
final richText = text.textSpan as TextSpan;
|
||||
|
||||
expect(richText.style?.fontSize, 16);
|
||||
expect(richText.style?.color, Colors.purple);
|
||||
expect(text.textAlign, TextAlign.center);
|
||||
expect(text.maxLines, 2);
|
||||
expect(text.overflow, TextOverflow.ellipsis);
|
||||
});
|
||||
|
||||
testWidgets('handles text with special characters', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with & < > " \' characters'),
|
||||
);
|
||||
|
||||
expect(find.byType(RichText), findsOneWidget);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
expect(_concatenateText(spans), 'Text with & < > " \' characters');
|
||||
});
|
||||
|
||||
group('bold', () {
|
||||
testWidgets('renders bold text with <b> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <b>bold</b> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final boldSpan = _findSpan(spans, 'bold');
|
||||
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
expect(_concatenateText(spans), 'This is bold text');
|
||||
});
|
||||
|
||||
testWidgets('renders bold text with <strong> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('This is <strong>strong</strong> text'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final strongSpan = _findSpan(spans, 'strong');
|
||||
|
||||
expect(strongSpan.style?.fontWeight, FontWeight.bold);
|
||||
});
|
||||
|
||||
testWidgets('handles nested bold tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
const ImmichHtmlText('Text with <b>bold and <strong>nested</strong></b>'),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
|
||||
final nestedSpan = _findSpan(spans, 'nested');
|
||||
expect(nestedSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
final boldSpan = _findSpan(spans, 'bold and ');
|
||||
expect(boldSpan.style?.fontWeight, FontWeight.bold);
|
||||
|
||||
expect(_concatenateText(spans), 'Text with bold and nested');
|
||||
});
|
||||
});
|
||||
|
||||
group('link', () {
|
||||
testWidgets('renders link text with <link> tag', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'This is a <link>custom link</link> text',
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'custom link');
|
||||
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
});
|
||||
|
||||
testWidgets('handles link tap with callback', (tester) async {
|
||||
var linkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Tap <link>here</link>',
|
||||
linkHandlers: {'link': () => linkTapped = true},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'here');
|
||||
expect(linkSpan.recognizer, isA<TapGestureRecognizer>());
|
||||
|
||||
_triggerTap(linkSpan);
|
||||
expect(linkTapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('handles custom prefixed link tags', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Refer to <docs-link>docs</docs-link> and <other-link>other</other-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () {},
|
||||
'other-link': () {},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final docsSpan = _findSpan(spans, 'docs');
|
||||
final otherSpan = _findSpan(spans, 'other');
|
||||
|
||||
expect(docsSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(otherSpan.style?.decoration, TextDecoration.underline);
|
||||
});
|
||||
|
||||
testWidgets('applies custom link style', (tester) async {
|
||||
const customLinkStyle = TextStyle(
|
||||
color: Colors.red,
|
||||
decoration: TextDecoration.overline,
|
||||
);
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Click <link>here</link>',
|
||||
linkStyle: customLinkStyle,
|
||||
linkHandlers: {'link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'here');
|
||||
|
||||
expect(linkSpan.style?.color, Colors.red);
|
||||
expect(linkSpan.style?.decoration, TextDecoration.overline);
|
||||
});
|
||||
|
||||
testWidgets('link without handler renders but is not tappable', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Link without handler: <link>click me</link>',
|
||||
linkHandlers: {'other-link': () {}},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final linkSpan = _findSpan(spans, 'click me');
|
||||
|
||||
expect(linkSpan.style?.decoration, TextDecoration.underline);
|
||||
expect(linkSpan.recognizer, isNull);
|
||||
});
|
||||
|
||||
testWidgets('handles multiple links with different handlers', (tester) async {
|
||||
var firstLinkTapped = false;
|
||||
var secondLinkTapped = false;
|
||||
|
||||
await tester.pumpTestWidget(
|
||||
ImmichHtmlText(
|
||||
'Go to <docs-link>docs</docs-link> or <help-link>help</help-link>',
|
||||
linkHandlers: {
|
||||
'docs-link': () => firstLinkTapped = true,
|
||||
'help-link': () => secondLinkTapped = true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final spans = _getContentSpans(tester);
|
||||
final docsSpan = _findSpan(spans, 'docs');
|
||||
final helpSpan = _findSpan(spans, 'help');
|
||||
|
||||
_triggerTap(docsSpan);
|
||||
expect(firstLinkTapped, isTrue);
|
||||
expect(secondLinkTapped, isFalse);
|
||||
|
||||
_triggerTap(helpSpan);
|
||||
expect(secondLinkTapped, isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
9
mobile/packages/ui/test/test_utils.dart
Normal file
9
mobile/packages/ui/test/test_utils.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
extension WidgetTesterExtension on WidgetTester {
|
||||
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
|
||||
Future<void> pumpTestWidget(Widget widget) {
|
||||
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.5.0+3031
|
||||
version: 2.5.1+3032
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -14951,7 +14951,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.5.0
|
||||
* 2.5.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "0.0.1",
|
||||
"version": "2.5.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user