Merge branch 'main' into fcast

This commit is contained in:
Will 2026-08-07 09:13:43 -05:00 committed by GitHub
commit 9b6ffb2342
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 69 additions and 49 deletions

View file

@ -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

View file

@ -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);

View file

@ -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),
),
],
),
),

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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"

View file

@ -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() {

View file

@ -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]>>;
};

View file

@ -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: {

View file

@ -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,