mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat: show remainder assets info (#21114)
* feat: show remainder assets info * pr feedback
This commit is contained in:
parent
66c657ca8a
commit
ab2849781a
11 changed files with 195 additions and 45 deletions
|
|
@ -16,10 +16,6 @@ class LocalImageRequest extends ImageRequest {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Stopwatch? stopwatch;
|
|
||||||
if (!kReleaseMode) {
|
|
||||||
stopwatch = Stopwatch()..start();
|
|
||||||
}
|
|
||||||
final Map<String, int> info = await thumbnailApi.requestImage(
|
final Map<String, int> info = await thumbnailApi.requestImage(
|
||||||
localId,
|
localId,
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
|
|
@ -27,19 +23,13 @@ class LocalImageRequest extends ImageRequest {
|
||||||
height: height,
|
height: height,
|
||||||
isVideo: assetType == AssetType.video,
|
isVideo: assetType == AssetType.video,
|
||||||
);
|
);
|
||||||
if (!kReleaseMode) {
|
|
||||||
stopwatch!.stop();
|
|
||||||
debugPrint('Local request $requestId took ${stopwatch.elapsedMilliseconds}ms for $localId of $width x $height');
|
|
||||||
}
|
|
||||||
final frame = await _fromPlatformImage(info);
|
final frame = await _fromPlatformImage(info);
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> _onCancelled() {
|
Future<void> _onCancelled() {
|
||||||
if (!kReleaseMode) {
|
|
||||||
debugPrint('Local image request $requestId for $localId of size $width x $height was cancelled');
|
|
||||||
}
|
|
||||||
return thumbnailApi.cancelImageRequest(requestId);
|
return thumbnailApi.cancelImageRequest(requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,11 @@ class RemoteImageRequest extends ImageRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Stopwatch? stopwatch;
|
|
||||||
if (!kReleaseMode) {
|
|
||||||
stopwatch = Stopwatch()..start();
|
|
||||||
}
|
|
||||||
final buffer = await _downloadImage(uri);
|
final buffer = await _downloadImage(uri);
|
||||||
if (buffer == null) {
|
if (buffer == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!kReleaseMode) {
|
|
||||||
stopwatch!.stop();
|
|
||||||
debugPrint('Remote image download $requestId took ${stopwatch.elapsedMilliseconds}ms for $uri');
|
|
||||||
}
|
|
||||||
return await _decodeBuffer(buffer, decode, scale);
|
return await _decodeBuffer(buffer, decode, scale);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
|
|
@ -139,8 +132,5 @@ class RemoteImageRequest extends ImageRequest {
|
||||||
void _onCancelled() {
|
void _onCancelled() {
|
||||||
_request?.abort();
|
_request?.abort();
|
||||||
_request = null;
|
_request = null;
|
||||||
if (!kReleaseMode) {
|
|
||||||
debugPrint('Cancelled remote image request $requestId for $uri');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,5 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void _onCancelled() {
|
void _onCancelled() {}
|
||||||
if (!kReleaseMode) {
|
|
||||||
debugPrint('Thumbhash request $requestId for $thumbhash was cancelled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
|
|
@ -135,4 +137,22 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||||
|
|
||||||
return query.map((localAsset) => localAsset.toDto()).get();
|
return query.map((localAsset) => localAsset.toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FutureOr<List<LocalAlbum>> getSourceAlbums(String localAssetId) {
|
||||||
|
final query = _db.localAlbumEntity.select()
|
||||||
|
..where(
|
||||||
|
(lae) =>
|
||||||
|
existsQuery(
|
||||||
|
_db.localAlbumAssetEntity.selectOnly()
|
||||||
|
..addColumns([_db.localAlbumAssetEntity.albumId])
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equalsExp(lae.id) &
|
||||||
|
_db.localAlbumAssetEntity.assetId.equals(localAssetId),
|
||||||
|
),
|
||||||
|
) &
|
||||||
|
lae.backupSelection.equalsValue(BackupSelection.selected),
|
||||||
|
)
|
||||||
|
..orderBy([(lae) => OrderingTerm.asc(lae.name)]);
|
||||||
|
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ class _RemainderCard extends ConsumerWidget {
|
||||||
title: "backup_controller_page_remainder".tr(),
|
title: "backup_controller_page_remainder".tr(),
|
||||||
subtitle: "backup_controller_page_remainder_sub".tr(),
|
subtitle: "backup_controller_page_remainder_sub".tr(),
|
||||||
info: remainderCount.toString(),
|
info: remainderCount.toString(),
|
||||||
|
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
mobile/lib/pages/backup/drift_backup_asset_detail.page.dart
Normal file
90
mobile/lib/pages/backup/drift_backup_asset_detail.page.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class DriftBackupAssetDetailPage extends ConsumerWidget {
|
||||||
|
const DriftBackupAssetDetailPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
AsyncValue<List<LocalAsset>> result = ref.watch(driftBackupCandidateProvider);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text('backup_controller_page_remainder'.t(context: context))),
|
||||||
|
body: result.when(
|
||||||
|
data: (List<LocalAsset> candidates) {
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
|
separatorBuilder: (context, index) => Divider(color: context.colorScheme.outlineVariant),
|
||||||
|
itemCount: candidates.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final asset = candidates[index];
|
||||||
|
final albumsAsyncValue = ref.watch(driftCandidateBackupAlbumInfoProvider(asset.id));
|
||||||
|
return LargeLeadingTile(
|
||||||
|
title: Text(
|
||||||
|
asset.name,
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500, fontSize: 16),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
asset.createdAt.toString(),
|
||||||
|
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
asset.checksum ?? "N/A",
|
||||||
|
style: TextStyle(fontSize: 13.0, color: context.colorScheme.onSurfaceSecondary),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
albumsAsyncValue.when(
|
||||||
|
data: (albums) {
|
||||||
|
if (albums.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Text(
|
||||||
|
albums.map((a) => a.name).join(', '),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (error, stackTrace) =>
|
||||||
|
Text('Error: $error', style: TextStyle(color: context.colorScheme.error)),
|
||||||
|
loading: () => const SizedBox(height: 16, width: 16, child: CircularProgressIndicator.adaptive()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
child: Thumbnail(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)),
|
||||||
|
onTap: () async {
|
||||||
|
await context.maybePop();
|
||||||
|
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||||
|
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
error: (Object error, StackTrace stackTrace) {
|
||||||
|
return Center(child: Text('Error: $error'));
|
||||||
|
},
|
||||||
|
loading: () {
|
||||||
|
return const SizedBox(height: 48, width: 48, child: Center(child: CircularProgressIndicator.adaptive()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
|
|
@ -67,7 +68,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.image_search),
|
icon: const Icon(Icons.image_search),
|
||||||
tooltip: 'view_in_timeline',
|
tooltip: 'view_in_timeline'.t(context: context),
|
||||||
),
|
),
|
||||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||||
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
|
@ -356,3 +360,19 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) async {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.read(backupRepositoryProvider).getCandidates(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family<List<LocalAlbum>, String>((
|
||||||
|
ref,
|
||||||
|
assetId,
|
||||||
|
) {
|
||||||
|
return ref.read(backupRepositoryProvider).getSourceAlbums(assetId);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
|
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
|
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
|
||||||
|
import 'package:immich_mobile/pages/backup/drift_backup_asset_detail.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart';
|
import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
|
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
||||||
|
|
@ -341,6 +342,7 @@ class AppRouter extends RootStackRouter {
|
||||||
AutoRoute(page: DriftCropImageRoute.page),
|
AutoRoute(page: DriftCropImageRoute.page),
|
||||||
AutoRoute(page: DriftFilterImageRoute.page),
|
AutoRoute(page: DriftFilterImageRoute.page),
|
||||||
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|
|
||||||
|
|
@ -796,6 +796,22 @@ class DriftBackupAlbumSelectionRoute extends PageRouteInfo<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DriftBackupAssetDetailPage]
|
||||||
|
class DriftBackupAssetDetailRoute extends PageRouteInfo<void> {
|
||||||
|
const DriftBackupAssetDetailRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DriftBackupAssetDetailRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DriftBackupAssetDetailRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DriftBackupAssetDetailPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftBackupOptionsPage]
|
/// [DriftBackupOptionsPage]
|
||||||
class DriftBackupOptionsRoute extends PageRouteInfo<void> {
|
class DriftBackupOptionsRoute extends PageRouteInfo<void> {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
|
||||||
class BackupInfoCard extends StatelessWidget {
|
class BackupInfoCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String info;
|
final String info;
|
||||||
const BackupInfoCard({super.key, required this.title, required this.subtitle, required this.info});
|
final VoidCallback? onTap;
|
||||||
|
const BackupInfoCard({super.key, required this.title, required this.subtitle, required this.info, this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -20,24 +22,46 @@ class BackupInfoCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
borderOnForeground: false,
|
borderOnForeground: false,
|
||||||
child: ListTile(
|
child: Column(
|
||||||
minVerticalPadding: 18,
|
children: [
|
||||||
isThreeLine: true,
|
ListTile(
|
||||||
title: Text(title, style: context.textTheme.titleMedium),
|
minVerticalPadding: 18,
|
||||||
subtitle: Padding(
|
isThreeLine: true,
|
||||||
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
title: Text(title, style: context.textTheme.titleMedium),
|
||||||
child: Text(
|
subtitle: Padding(
|
||||||
subtitle,
|
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
child: Text(
|
||||||
|
subtitle,
|
||||||
|
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(info, style: context.textTheme.titleLarge),
|
||||||
|
Text("backup_info_card_assets", style: context.textTheme.labelLarge).tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
trailing: Column(
|
if (onTap != null) ...[
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const Divider(height: 0),
|
||||||
children: [
|
ListTile(
|
||||||
Text(info, style: context.textTheme.titleLarge),
|
enableFeedback: true,
|
||||||
Text("backup_info_card_assets", style: context.textTheme.labelLarge).tr(),
|
visualDensity: VisualDensity.compact,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
title: Text(
|
||||||
|
"view_details".t(context: context),
|
||||||
|
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.arrow_forward_ios, size: 16, color: context.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue