mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge branch 'main' into fcast
This commit is contained in:
commit
9b6ffb2342
10 changed files with 69 additions and 49 deletions
1
.github/workflows/pr-label-validation.yml
vendored
1
.github/workflows/pr-label-validation.yml
vendored
|
|
@ -8,7 +8,6 @@ permissions: {}
|
|||
|
||||
jobs:
|
||||
validate-release-label:
|
||||
if: ${{ !startsWith(github.event.pull_request.head.ref, 'mergify/merge-queue/') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ class AssetStackRow extends ConsumerWidget {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final double opacity =
|
||||
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import 'package:immich_ui/immich_ui.dart';
|
|||
List<Widget> _actionColumnButtons(BuildContext context, WidgetRef ref, List<ActionBuilder> actions) => actions
|
||||
.map((a) => a.create(context, ref))
|
||||
.nonNulls
|
||||
.map((item) => ImmichColumnButton(icon: item.icon, label: item.label, onPressed: item.onAction))
|
||||
.map<ImmichColumnButton>(
|
||||
(item) => .new(icon: item.icon, label: item.label, onPressed: item.onAction, onLongPress: item.onSecondaryAction),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
class ViewerBottomBar extends ConsumerWidget {
|
||||
|
|
@ -51,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||
UploadAction(source: .viewer, showProgress: true),
|
||||
EditAssetAction(source: .viewer),
|
||||
]),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
if (asset.hasRemote) ImmichColorOverride(color: null, child: AddActionButton(originalTheme: originalTheme)),
|
||||
],
|
||||
..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]),
|
||||
],
|
||||
|
|
@ -94,7 +96,10 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||
if (asset.isImage) OcrToggleButton(asset: asset),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
ImmichColorOverride(
|
||||
color: Colors.white,
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import 'package:immich_mobile/routing/router.dart';
|
|||
@visibleForTesting
|
||||
bool isRouteInStack(Ref ref, String routeName) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
void onChange() => ref.invalidateSelf();
|
||||
router.addListener(onChange);
|
||||
ref.onDispose(() => router.removeListener(onChange));
|
||||
return router.stackData.any((route) => route.name == routeName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
ref.invalidate(inLockedViewProvider);
|
||||
ref.invalidate(isAssetViewerOpenProvider);
|
||||
unawaited(
|
||||
Future(() {
|
||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||
|
|
@ -21,4 +23,10 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
ref.invalidate(inLockedViewProvider);
|
||||
ref.invalidate(isAssetViewerOpenProvider);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,13 +60,7 @@ depends = ["//:open-api-dart"]
|
|||
[tasks."codegen:translation"]
|
||||
alias = "translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
run = [
|
||||
{ task = "//:i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"i18n:loader",
|
||||
"i18n:keys",
|
||||
] },
|
||||
]
|
||||
depends = ["//:i18n:format-fix", "i18n:loader", "i18n:keys"]
|
||||
|
||||
[tasks."codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
|
|
@ -129,6 +123,7 @@ run = [
|
|||
"dart format lib/generated/codegen_loader.g.dart",
|
||||
]
|
||||
depends = ["//:open-api-dart"]
|
||||
wait_for = ["//:i18n:format-fix"]
|
||||
|
||||
[tasks."i18n:keys"]
|
||||
description = "Generate i18n keys"
|
||||
|
|
@ -137,6 +132,7 @@ run = [
|
|||
"dart run bin/generate_keys.dart",
|
||||
"dart format lib/generated/translations.g.dart",
|
||||
]
|
||||
wait_for = ["//:i18n:format-fix"]
|
||||
|
||||
[tasks."analyze:dart"]
|
||||
description = "Run Dart analysis"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
|||
ViewRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class BaseService {
|
||||
|
|
@ -191,7 +191,7 @@ export class BaseService {
|
|||
);
|
||||
}
|
||||
|
||||
static create<T extends BaseService>(Service: ClassConstructor<T>, ctx: BaseService) {
|
||||
static create<T extends ClassConstructor<typeof BaseService>>(Service: T, ctx: BaseService) {
|
||||
const service = new Service(
|
||||
LoggingRepository.create(),
|
||||
ctx.accessRepository,
|
||||
|
|
@ -242,6 +242,7 @@ export class BaseService {
|
|||
ctx.trashRepository,
|
||||
ctx.userRepository,
|
||||
ctx.versionRepository,
|
||||
ctx.videoStreamRepository,
|
||||
ctx.viewRepository,
|
||||
ctx.websocketRepository,
|
||||
ctx.workflowRepository,
|
||||
|
|
@ -249,7 +250,7 @@ export class BaseService {
|
|||
|
||||
service.logger.setContext(BaseService.name);
|
||||
|
||||
return service as T;
|
||||
return service as InstanceType<T>;
|
||||
}
|
||||
|
||||
get worker() {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
UserMetadataKey,
|
||||
WorkflowType,
|
||||
} from 'src/enum';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
export type DeepPartial<T> = T extends Date
|
||||
? T
|
||||
|
|
@ -647,7 +648,10 @@ export type JSONSchemaProperty = {
|
|||
required?: string[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export interface ClassConstructor<T = any> extends Function {
|
||||
new (...args: any[]): T;
|
||||
}
|
||||
export type ClassConstructor<T> = T extends new (...args: infer R) => infer L
|
||||
? new (...args: R) => L
|
||||
: new (...args: any[]) => unknown;
|
||||
|
||||
export type ClassConstructorsToInstances<T extends readonly ClassConstructor<unknown>[]> = {
|
||||
[K in keyof T]: InstanceType<T[K]> | Mocked<InstanceType<T[K]>>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import { UserTable } from 'src/schema/tables/user.table';
|
|||
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
|
||||
import { MetadataService } from 'src/services/metadata.service';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { ClassConstructor, UploadFile } from 'src/types';
|
||||
import { ClassConstructor, ClassConstructorsToInstances, UploadFile } from 'src/types';
|
||||
import { getConfig, updateConfig } from 'src/utils/config';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
|
|
@ -88,29 +88,34 @@ import { Mocked } from 'vitest';
|
|||
export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets');
|
||||
|
||||
type MediumTestOptions = {
|
||||
mock: ClassConstructor<any>[];
|
||||
real: ClassConstructor<any>[];
|
||||
mock: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>;
|
||||
real: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>;
|
||||
database: Kysely<DB>;
|
||||
};
|
||||
|
||||
export const newMediumService = <S extends BaseService>(Service: ClassConstructor<S>, options: MediumTestOptions) => {
|
||||
type BaseServiceDeps = typeof BASE_SERVICE_DEPENDENCIES;
|
||||
|
||||
export const newMediumService = <S extends ClassConstructor<typeof BaseService>>(
|
||||
Service: S,
|
||||
options: MediumTestOptions,
|
||||
) => {
|
||||
const ctx = new MediumTestContext(Service, options);
|
||||
return { sut: ctx.sut, ctx };
|
||||
};
|
||||
|
||||
export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
export class MediumTestContext<S extends ClassConstructor<typeof BaseService> = ClassConstructor<typeof BaseService>> {
|
||||
private repoCache: Record<string, any> = {};
|
||||
private sutDeps: any[];
|
||||
private sutDeps: ClassConstructorsToInstances<BaseServiceDeps>;
|
||||
|
||||
sut: S;
|
||||
sut: InstanceType<S>;
|
||||
database: Kysely<DB>;
|
||||
|
||||
constructor(
|
||||
Service: ClassConstructor<S>,
|
||||
Service: S,
|
||||
private options: MediumTestOptions,
|
||||
) {
|
||||
this.sutDeps = this.makeDeps(options);
|
||||
this.sut = new Service(...this.sutDeps);
|
||||
this.sut = new Service(...this.sutDeps) as InstanceType<S>;
|
||||
this.database = options.database;
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +133,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
throw new Error(`Real repository ${dep.name} is not a valid dependency`);
|
||||
}
|
||||
}
|
||||
return (deps as ClassConstructor<any>[]).map((dep) => {
|
||||
return deps.map((dep) => {
|
||||
if (options.real.includes(dep)) {
|
||||
return this.get(dep);
|
||||
}
|
||||
|
|
@ -136,10 +141,10 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
if (options.mock.includes(dep)) {
|
||||
return newMockRepository(dep);
|
||||
}
|
||||
});
|
||||
}) as unknown as ClassConstructorsToInstances<BaseServiceDeps>;
|
||||
}
|
||||
|
||||
get<T>(key: ClassConstructor<T>): T {
|
||||
get<T extends BaseServiceDeps[number]>(key: T): InstanceType<T> {
|
||||
if (!Object.hasOwn(this.repoCache, key.name)) {
|
||||
const real = newRealRepository(key, this.options.database);
|
||||
this.repoCache[key.name] = real;
|
||||
|
|
@ -148,8 +153,8 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
return this.repoCache[key.name];
|
||||
}
|
||||
|
||||
getMock<T, R = Mocked<T>>(key: ClassConstructor<T>): R {
|
||||
const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any);
|
||||
getMock<T extends BaseServiceDeps[number], R = Mocked<InstanceType<T>>>(key: T): R {
|
||||
const index = BASE_SERVICE_DEPENDENCIES.indexOf(key);
|
||||
if (index === -1 || !this.options.mock.includes(key)) {
|
||||
throw new Error(`getMock called with a key that is not a mock: ${key.name}`);
|
||||
}
|
||||
|
|
@ -328,7 +333,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
}
|
||||
}
|
||||
|
||||
export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||
export class SyncTestContext extends MediumTestContext<typeof SyncService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(SyncService, {
|
||||
database,
|
||||
|
|
@ -379,7 +384,7 @@ const mockStats = {
|
|||
birthtimeMs: 0,
|
||||
};
|
||||
|
||||
export class ExifTestContext extends MediumTestContext<MetadataService> {
|
||||
export class ExifTestContext extends MediumTestContext<typeof MetadataService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(MetadataService, {
|
||||
database,
|
||||
|
|
@ -431,7 +436,7 @@ export class ExifTestContext extends MediumTestContext<MetadataService> {
|
|||
}
|
||||
}
|
||||
|
||||
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely<DB>): InstanceType<T> => {
|
||||
switch (key) {
|
||||
case AccessRepository:
|
||||
case AlbumRepository:
|
||||
|
|
@ -457,41 +462,41 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
|||
case UserRepository:
|
||||
case VersionHistoryRepository:
|
||||
case WorkflowRepository: {
|
||||
return new key(db);
|
||||
return new key(db) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case ConfigRepository:
|
||||
case CryptoRepository: {
|
||||
return new key();
|
||||
return new key() as InstanceType<T>;
|
||||
}
|
||||
|
||||
case DatabaseRepository: {
|
||||
return new key(db, LoggingRepository.create(), new ConfigRepository());
|
||||
return new key(db, LoggingRepository.create(), new ConfigRepository()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case EmailRepository: {
|
||||
return new key(LoggingRepository.create());
|
||||
return new key(LoggingRepository.create()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case MediaRepository:
|
||||
case MetadataRepository: {
|
||||
return new key(LoggingRepository.create());
|
||||
return new key(LoggingRepository.create()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case PluginRepository: {
|
||||
return new key(db, LoggingRepository.create());
|
||||
return new key(db, LoggingRepository.create()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case StorageRepository: {
|
||||
return new key(LoggingRepository.create());
|
||||
return new key(LoggingRepository.create()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case TagRepository: {
|
||||
return new key(db, LoggingRepository.create());
|
||||
return new key(db, LoggingRepository.create()) as InstanceType<T>;
|
||||
}
|
||||
|
||||
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
|
||||
return new key() as unknown as T;
|
||||
case LoggingRepository: {
|
||||
return new key(undefined, undefined) as InstanceType<T>;
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { getKyselyDB } from 'test/utils';
|
|||
|
||||
let isInitialized = false;
|
||||
|
||||
class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
|
||||
class WorkflowTestContext extends MediumTestContext<typeof WorkflowExecutionService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(WorkflowExecutionService, {
|
||||
database,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue