Merge ce6097ce57 into 963862b1b9
commit
a08b1abca6
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:openapi/api.dart';
|
||||
|
||||
class QueueStatistics {
|
||||
final int active;
|
||||
final int completed;
|
||||
final int failed;
|
||||
final int delayed;
|
||||
final int waiting;
|
||||
final int paused;
|
||||
|
||||
const QueueStatistics({
|
||||
required this.active,
|
||||
required this.completed,
|
||||
required this.failed,
|
||||
required this.delayed,
|
||||
required this.waiting,
|
||||
required this.paused,
|
||||
});
|
||||
|
||||
factory QueueStatistics.fromDto(QueueStatisticsDto dto) => QueueStatistics(
|
||||
active: dto.active,
|
||||
completed: dto.completed,
|
||||
failed: dto.failed,
|
||||
delayed: dto.delayed,
|
||||
waiting: dto.waiting,
|
||||
paused: dto.paused,
|
||||
);
|
||||
|
||||
factory QueueStatistics.fromLegacyDto(QueueStatisticsDto dto) => QueueStatistics.fromDto(dto);
|
||||
}
|
||||
|
||||
class QueueResponse {
|
||||
final String name;
|
||||
final bool isPaused;
|
||||
final QueueStatistics statistics;
|
||||
|
||||
const QueueResponse({required this.name, required this.isPaused, required this.statistics});
|
||||
|
||||
factory QueueResponse.fromDto(QueueResponseDto dto) =>
|
||||
QueueResponse(name: dto.name.value, isPaused: dto.isPaused, statistics: QueueStatistics.fromDto(dto.statistics));
|
||||
|
||||
factory QueueResponse.fromLegacyDto(QueueResponseLegacyDto dto, String queueName) => QueueResponse(
|
||||
name: queueName,
|
||||
isPaused: dto.queueStatus.isPaused,
|
||||
statistics: QueueStatistics.fromLegacyDto(dto.jobCounts),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/settings/job_queue_settings.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_card.dart';
|
||||
|
||||
enum AdminSettingSection {
|
||||
jobQueue('admin.queues', Icons.queue_outlined, 'admin.queue_details');
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
Widget get widget => switch (this) {
|
||||
AdminSettingSection.jobQueue => const JobQueueSettings(),
|
||||
};
|
||||
|
||||
const AdminSettingSection(this.title, this.icon, this.subtitle);
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class AdminSettingsPage extends StatelessWidget {
|
||||
const AdminSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: const Text('administration').tr()),
|
||||
body: const _AdminSettingsLayout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminSettingsLayout extends StatelessWidget {
|
||||
const _AdminSettingsLayout();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> settings = AdminSettingSection.values
|
||||
.map(
|
||||
(setting) => SettingsCard(
|
||||
title: setting.title.tr(),
|
||||
subtitle: setting.subtitle.tr(),
|
||||
icon: setting.icon,
|
||||
settingRoute: AdminSettingsSubRoute(section: setting),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: settings);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class AdminSettingsSubPage extends StatelessWidget {
|
||||
const AdminSettingsSubPage(this.section, {super.key});
|
||||
|
||||
final AdminSettingSection section;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.locale;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
|
||||
body: section.widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/models/queues/queue_response.model.dart';
|
||||
import 'package:immich_mobile/repositories/queues_api.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final queuesProvider = StateNotifierProvider<QueuesNotifier, List<QueueResponse>>((ref) {
|
||||
final repository = ref.watch(queuesApiRepositoryProvider);
|
||||
return QueuesNotifier(repository);
|
||||
});
|
||||
|
||||
final queueByNameProvider = Provider.family<QueueResponse?, String>((ref, name) {
|
||||
final queues = ref.watch(queuesProvider);
|
||||
for (final queue in queues) {
|
||||
if (queue.name == name) {
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
class QueuesNotifier extends StateNotifier<List<QueueResponse>> {
|
||||
QueuesNotifier(this._repository) : super([]) {
|
||||
_refresh();
|
||||
_timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh());
|
||||
}
|
||||
|
||||
final QueuesApiRepository _repository;
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final queues = await _repository.getAll();
|
||||
if (!_disposed) {
|
||||
state = queues;
|
||||
}
|
||||
} catch (e) {
|
||||
dPrint(() => 'QueuesNotifier error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pauseQueue(QueueName name) async {
|
||||
try {
|
||||
final queue = await _repository.updateQueue(name, true);
|
||||
_updateQueueInState(queue);
|
||||
} catch (e) {
|
||||
dPrint(() => 'pauseQueue error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resumeQueue(QueueName name) async {
|
||||
try {
|
||||
final queue = await _repository.updateQueue(name, false);
|
||||
_updateQueueInState(queue);
|
||||
} catch (e) {
|
||||
dPrint(() => 'resumeQueue error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emptyQueue(QueueName name, {bool failed = false}) async {
|
||||
try {
|
||||
final queue = await _repository.emptyQueue(name, failed: failed);
|
||||
_updateQueueInState(queue);
|
||||
} catch (e) {
|
||||
dPrint(() => 'emptyQueue error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> runCommand(QueueName name, QueueCommand command, {bool? force}) async {
|
||||
try {
|
||||
final queue = await _repository.runQueueCommand(name, command, force: force);
|
||||
_updateQueueInState(queue);
|
||||
} catch (e) {
|
||||
dPrint(() => 'runCommand error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateQueueInState(QueueResponse updatedQueue) {
|
||||
final index = state.indexWhere((q) => q.name == updatedQueue.name);
|
||||
if (index >= 0) {
|
||||
final newState = List<QueueResponse>.from(state);
|
||||
newState[index] = updatedQueue;
|
||||
state = newState;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/queues/queue_response.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final queuesApiRepositoryProvider = Provider(
|
||||
(ref) => QueuesApiRepository(ref.watch(apiServiceProvider).queuesApi, ref.watch(apiServiceProvider).jobsApi),
|
||||
);
|
||||
|
||||
class QueuesApiRepository extends ApiRepository {
|
||||
final QueuesApi _api;
|
||||
final JobsApi _jobsApi;
|
||||
|
||||
QueuesApiRepository(this._api, this._jobsApi);
|
||||
|
||||
Future<List<QueueResponse>> getAll() async {
|
||||
final response = await checkNull(_api.getQueues());
|
||||
return response.map((dto) => QueueResponse.fromDto(dto)).toList();
|
||||
}
|
||||
|
||||
Future<QueueResponse> updateQueue(QueueName name, bool isPaused) async {
|
||||
final response = await checkNull(_api.updateQueue(name, QueueUpdateDto(isPaused: isPaused)));
|
||||
return QueueResponse.fromDto(response);
|
||||
}
|
||||
|
||||
Future<QueueResponse> emptyQueue(QueueName name, {bool failed = false}) async {
|
||||
await _api.emptyQueue(name, QueueDeleteDto(failed: failed));
|
||||
try {
|
||||
final response = await checkNull(_api.getQueue(name));
|
||||
return QueueResponse.fromDto(response);
|
||||
} catch (e) {
|
||||
return QueueResponse(
|
||||
name: name.value,
|
||||
isPaused: false,
|
||||
statistics: const QueueStatistics(active: 0, completed: 0, failed: 0, delayed: 0, waiting: 0, paused: 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<QueueResponse> runQueueCommand(QueueName name, QueueCommand command, {bool? force}) async {
|
||||
final response = await checkNull(
|
||||
_jobsApi.runQueueCommandLegacy(name, QueueCommandDto(command: command, force: force)),
|
||||
);
|
||||
return QueueResponse.fromLegacyDto(response, name.value);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/app_log_detail.page.dart';
|
|||
import 'package:immich_mobile/pages/common/headers_settings.page.dart';
|
||||
import 'package:immich_mobile/pages/common/settings.page.dart';
|
||||
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/pages/settings/admin_settings.page.dart';
|
||||
import 'package:immich_mobile/pages/common/tab_shell.page.dart';
|
||||
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
|
||||
import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart';
|
||||
|
|
@ -131,6 +132,8 @@ class AppRouter extends RootStackRouter {
|
|||
AutoRoute(page: ProfilePictureCropRoute.page),
|
||||
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AdminSettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AdminSettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,69 @@
|
|||
|
||||
part of 'router.dart';
|
||||
|
||||
/// generated route for
|
||||
/// [AdminSettingsPage]
|
||||
class AdminSettingsRoute extends PageRouteInfo<void> {
|
||||
const AdminSettingsRoute({List<PageRouteInfo>? children})
|
||||
: super(AdminSettingsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'AdminSettingsRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const AdminSettingsPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AdminSettingsSubPage]
|
||||
class AdminSettingsSubRoute extends PageRouteInfo<AdminSettingsSubRouteArgs> {
|
||||
AdminSettingsSubRoute({
|
||||
required AdminSettingSection section,
|
||||
Key? key,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
AdminSettingsSubRoute.name,
|
||||
args: AdminSettingsSubRouteArgs(section: section, key: key),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'AdminSettingsSubRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<AdminSettingsSubRouteArgs>();
|
||||
return AdminSettingsSubPage(args.section, key: args.key);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class AdminSettingsSubRouteArgs {
|
||||
const AdminSettingsSubRouteArgs({required this.section, this.key});
|
||||
|
||||
final AdminSettingSection section;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AdminSettingsSubRouteArgs{section: $section, key: $key}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! AdminSettingsSubRouteArgs) return false;
|
||||
return section == other.section && key == other.key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => section.hashCode ^ key.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AppLogDetailPage]
|
||||
class AppLogDetailRoute extends PageRouteInfo<AppLogDetailRouteArgs> {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class ApiService {
|
|||
late MemoriesApi memoriesApi;
|
||||
late SessionsApi sessionsApi;
|
||||
late TagsApi tagsApi;
|
||||
late QueuesApi queuesApi;
|
||||
late JobsApi jobsApi;
|
||||
|
||||
ApiService() {
|
||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
||||
|
|
@ -77,6 +79,8 @@ class ApiService {
|
|||
memoriesApi = MemoriesApi(_apiClient);
|
||||
sessionsApi = SessionsApi(_apiClient);
|
||||
tagsApi = TagsApi(_apiClient);
|
||||
queuesApi = QueuesApi(_apiClient);
|
||||
jobsApi = JobsApi(_apiClient);
|
||||
}
|
||||
|
||||
Future<String> resolveAndSetEndpoint(String serverUrl) async {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
|
||||
}
|
||||
|
||||
buildAdminSettingsButton() {
|
||||
return buildActionButton(
|
||||
Icons.admin_panel_settings_outlined,
|
||||
"administration",
|
||||
() => context.pushRoute(const AdminSettingsRoute()),
|
||||
);
|
||||
}
|
||||
|
||||
buildFreeUpSpaceButton() {
|
||||
return buildActionButton(
|
||||
Icons.cleaning_services_outlined,
|
||||
|
|
@ -273,6 +281,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||
buildAppLogButton(),
|
||||
buildFreeUpSpaceButton(),
|
||||
buildSettingButton(),
|
||||
if (user?.isAdmin ?? false) buildAdminSettingsButton(),
|
||||
buildSignOutButton(),
|
||||
buildFooter(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,480 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/queues.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/queue_card_button.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class _ButtonConfig {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String action;
|
||||
|
||||
const _ButtonConfig({required this.icon, required this.label, required this.action});
|
||||
}
|
||||
|
||||
class JobQueueSettings extends ConsumerWidget {
|
||||
const JobQueueSettings({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.watch(queuesProvider);
|
||||
final queues = ref.watch(queuesProvider);
|
||||
|
||||
if (queues.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
itemCount: _visibleQueueNames.length,
|
||||
itemBuilder: (context, index) {
|
||||
final name = _visibleQueueNames[index];
|
||||
return _QueueTile(name: name);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const _visibleQueueNames = [
|
||||
'thumbnailGeneration',
|
||||
'metadataExtraction',
|
||||
'library',
|
||||
'sidecar',
|
||||
'smartSearch',
|
||||
'duplicateDetection',
|
||||
'faceDetection',
|
||||
'facialRecognition',
|
||||
'ocr',
|
||||
'videoConversion',
|
||||
'storageTemplateMigration',
|
||||
'migration',
|
||||
// 'backgroundTask',
|
||||
// 'search',
|
||||
// 'notifications',
|
||||
// 'backupDatabase',
|
||||
// 'workflow',
|
||||
// 'editor',
|
||||
];
|
||||
}
|
||||
|
||||
class _QueueTile extends ConsumerWidget {
|
||||
const _QueueTile({required this.name});
|
||||
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ref.watch(queuesProvider);
|
||||
final queue = ref.watch(queueByNameProvider(name));
|
||||
|
||||
if (queue == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final queueInfo = _getQueueInfo(name);
|
||||
final title = queueInfo['title']!;
|
||||
final subtitle = queueInfo['subtitle'];
|
||||
final icon = queueInfo['icon'] as IconData;
|
||||
final actions = queueInfo['actions'] as Map<String, dynamic>;
|
||||
|
||||
final stats = queue.statistics;
|
||||
final waitingCount = stats.waiting + stats.paused + stats.delayed;
|
||||
final isIdle = stats.active == 0 && waitingCount == 0 && !queue.isPaused;
|
||||
final hasWaitingJobs = waitingCount > 0;
|
||||
|
||||
final isDisabled = actions['disabled'] == true;
|
||||
final allText = actions['allText'] as String?;
|
||||
final refreshText = actions['refreshText'] as String?;
|
||||
final missingText = actions['missingText'] as String?;
|
||||
|
||||
final hasHeader = queue.isPaused || stats.active > 0;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
side: hasHeader
|
||||
? BorderSide(color: queue.isPaused ? Colors.orange.shade100 : Colors.green.shade100, width: 1.5)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasHeader)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: queue.isPaused ? Colors.orange.shade100 : Colors.green.shade100,
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
queue.isPaused ? tr('paused') : tr('active'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: queue.isPaused ? Colors.orange.shade800 : Colors.green.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 24, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(tr(title), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
if (subtitle != null) _buildInfoButton(context, tr(title), tr(subtitle)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (stats.failed > 0 || stats.delayed > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (stats.failed > 0)
|
||||
_buildBadge(
|
||||
context,
|
||||
'${tr('admin.jobs_failed')} ${stats.failed}',
|
||||
Icons.error_outline,
|
||||
Colors.red.shade100,
|
||||
Colors.red.shade800,
|
||||
),
|
||||
if (stats.delayed > 0)
|
||||
_buildBadge(
|
||||
context,
|
||||
'${tr('admin.jobs_delayed')} ${stats.delayed}',
|
||||
Icons.schedule,
|
||||
Colors.orange.shade100,
|
||||
Colors.orange.shade800,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
StatisticBox(
|
||||
label: tr('active'),
|
||||
value: stats.active,
|
||||
colorScheme: StatisticBoxColorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatisticBox(
|
||||
label: tr('waiting'),
|
||||
value: waitingCount,
|
||||
colorScheme: StatisticBoxColorScheme.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildActionButtons(
|
||||
ref,
|
||||
context,
|
||||
isDisabled: isDisabled,
|
||||
isIdle: isIdle,
|
||||
isPaused: queue.isPaused,
|
||||
hasWaitingJobs: hasWaitingJobs,
|
||||
allText: allText,
|
||||
refreshText: refreshText,
|
||||
missingText: missingText,
|
||||
queueName: name,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoButton(BuildContext context, String title, String description) {
|
||||
return InkWell(
|
||||
onTap: () => _showDescriptionPopup(context, title, description),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(Icons.info_outline, size: 20, color: Theme.of(context).primaryColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBadge(_, String text, IconData icon, Color backgroundColor, Color textColor) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor, borderRadius: const BorderRadius.all(Radius.circular(12))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 11, color: textColor, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(
|
||||
WidgetRef ref,
|
||||
_, {
|
||||
required bool isDisabled,
|
||||
required bool isIdle,
|
||||
required bool isPaused,
|
||||
required bool hasWaitingJobs,
|
||||
String? allText,
|
||||
String? refreshText,
|
||||
String? missingText,
|
||||
required String queueName,
|
||||
}) {
|
||||
if (isDisabled) {
|
||||
return Row(
|
||||
children: [
|
||||
QueueCardButton(
|
||||
icon: Icons.block,
|
||||
label: tr('disabled'),
|
||||
color: QueueCardButtonColor.secondary,
|
||||
disabled: true,
|
||||
onTap: () => _onAction(ref, 'disabled', queueName),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final buttons = _getButtonConfigs(
|
||||
isIdle: isIdle,
|
||||
isPaused: isPaused,
|
||||
hasWaitingJobs: hasWaitingJobs,
|
||||
allText: allText,
|
||||
refreshText: refreshText,
|
||||
missingText: missingText,
|
||||
);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
for (int i = 0; i < buttons.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: QueueCardButton(
|
||||
icon: buttons[i].icon,
|
||||
label: buttons[i].label,
|
||||
color: QueueCardButtonColor.outlined,
|
||||
onTap: () => _onAction(ref, buttons[i].action, queueName),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<_ButtonConfig> _getButtonConfigs({
|
||||
required bool isIdle,
|
||||
required bool isPaused,
|
||||
required bool hasWaitingJobs,
|
||||
String? allText,
|
||||
String? refreshText,
|
||||
String? missingText,
|
||||
}) {
|
||||
if (isIdle) {
|
||||
if (allText != null || refreshText != null || missingText != null) {
|
||||
return [
|
||||
if (allText != null) _ButtonConfig(icon: Icons.all_inclusive, label: allText, action: 'all'),
|
||||
if (refreshText != null) _ButtonConfig(icon: Icons.refresh, label: refreshText, action: 'refresh'),
|
||||
if (missingText != null) _ButtonConfig(icon: Icons.search, label: missingText, action: 'missing'),
|
||||
];
|
||||
}
|
||||
return [_ButtonConfig(icon: Icons.play_arrow, label: missingText ?? tr('start'), action: 'start')];
|
||||
}
|
||||
|
||||
return [
|
||||
if (hasWaitingJobs) _ButtonConfig(icon: Icons.clear, label: tr('clear'), action: 'clear'),
|
||||
_ButtonConfig(
|
||||
icon: isPaused ? Icons.fast_forward : Icons.pause,
|
||||
label: isPaused ? tr('resume') : tr('pause'),
|
||||
action: isPaused ? 'resume' : 'pause',
|
||||
),
|
||||
if (missingText != null) _ButtonConfig(icon: Icons.search, label: missingText, action: 'missing'),
|
||||
];
|
||||
}
|
||||
|
||||
void _onAction(WidgetRef ref, String action, String queueName) {
|
||||
final queueNameEnum = QueueName.values.firstWhere(
|
||||
(e) => e.value == queueName,
|
||||
orElse: () => QueueName.backgroundTask,
|
||||
);
|
||||
final notifier = ref.read(queuesProvider.notifier);
|
||||
|
||||
switch (action) {
|
||||
case 'disabled':
|
||||
break;
|
||||
case 'all':
|
||||
notifier.runCommand(queueNameEnum, QueueCommand.start, force: true);
|
||||
break;
|
||||
case 'refresh':
|
||||
notifier.runCommand(queueNameEnum, QueueCommand.start, force: null);
|
||||
break;
|
||||
case 'missing':
|
||||
case 'start':
|
||||
notifier.runCommand(queueNameEnum, QueueCommand.start, force: false);
|
||||
break;
|
||||
case 'pause':
|
||||
notifier.pauseQueue(queueNameEnum);
|
||||
break;
|
||||
case 'resume':
|
||||
notifier.resumeQueue(queueNameEnum);
|
||||
break;
|
||||
case 'clear':
|
||||
notifier.emptyQueue(queueNameEnum, failed: false);
|
||||
break;
|
||||
case 'clear-failed':
|
||||
notifier.emptyQueue(queueNameEnum, failed: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _showDescriptionPopup(BuildContext context, String title, String description) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(description),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getQueueInfo(String name) {
|
||||
return switch (name) {
|
||||
'thumbnailGeneration' => {
|
||||
'title': 'admin.thumbnail_generation_job',
|
||||
'subtitle': 'admin.thumbnail_generation_job_description',
|
||||
'icon': Icons.image_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'metadataExtraction' => {
|
||||
'title': 'admin.metadata_extraction_job',
|
||||
'subtitle': 'admin.metadata_extraction_job_description',
|
||||
'icon': Icons.table_chart_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'videoConversion' => {
|
||||
'title': 'admin.video_conversion_job',
|
||||
'subtitle': 'admin.video_conversion_job_description',
|
||||
'icon': Icons.movie_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'faceDetection' => {
|
||||
'title': 'admin.face_detection',
|
||||
'subtitle': 'admin.face_detection_description',
|
||||
'icon': Icons.face_outlined,
|
||||
'actions': {
|
||||
'allText': tr('reset'),
|
||||
'refreshText': tr('refresh'),
|
||||
'missingText': tr('missing'),
|
||||
'disabled': false,
|
||||
},
|
||||
},
|
||||
'facialRecognition' => {
|
||||
'title': 'admin.machine_learning_facial_recognition',
|
||||
'subtitle': 'admin.facial_recognition_job_description',
|
||||
'icon': Icons.tag_faces_outlined,
|
||||
'actions': {'allText': tr('reset'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'smartSearch' => {
|
||||
'title': 'admin.machine_learning_smart_search',
|
||||
'subtitle': 'admin.smart_search_job_description',
|
||||
'icon': Icons.image_search_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'duplicateDetection' => {
|
||||
'title': 'admin.machine_learning_duplicate_detection',
|
||||
'subtitle': 'admin.duplicate_detection_job_description',
|
||||
'icon': Icons.content_copy_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'backgroundTask' => {
|
||||
'title': 'admin.background_task_job',
|
||||
'subtitle': null,
|
||||
'icon': Icons.inbox_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
'storageTemplateMigration' => {
|
||||
'title': 'admin.storage_template_migration',
|
||||
'subtitle': null,
|
||||
'icon': Icons.folder_outlined,
|
||||
'actions': {'missingText': tr('start'), 'disabled': false},
|
||||
},
|
||||
'migration' => {
|
||||
'title': 'admin.migration_job',
|
||||
'subtitle': 'admin.migration_job_description',
|
||||
'icon': Icons.sync_outlined,
|
||||
'actions': {'missingText': tr('start'), 'disabled': false},
|
||||
},
|
||||
'search' => {
|
||||
'title': 'search',
|
||||
'subtitle': null,
|
||||
'icon': Icons.search_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
'sidecar' => {
|
||||
'title': 'admin.sidecar_job',
|
||||
'subtitle': 'admin.sidecar_job_description',
|
||||
'icon': Icons.code_outlined,
|
||||
'actions': {'allText': tr('sync'), 'missingText': tr('discover'), 'disabled': false},
|
||||
},
|
||||
'library' => {
|
||||
'title': 'external_libraries',
|
||||
'subtitle': 'admin.library_tasks_description',
|
||||
'icon': Icons.library_books_outlined,
|
||||
'actions': {'missingText': tr('rescan'), 'disabled': false},
|
||||
},
|
||||
'notifications' => {
|
||||
'title': 'notifications',
|
||||
'subtitle': null,
|
||||
'icon': Icons.notifications_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
'backupDatabase' => {
|
||||
'title': 'admin.backup_database',
|
||||
'subtitle': null,
|
||||
'icon': Icons.storage_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
'ocr' => {
|
||||
'title': 'admin.machine_learning_ocr',
|
||||
'subtitle': 'admin.ocr_job_description',
|
||||
'icon': Icons.text_fields_outlined,
|
||||
'actions': {'allText': tr('all'), 'missingText': tr('missing'), 'disabled': false},
|
||||
},
|
||||
'workflow' => {
|
||||
'title': 'workflows',
|
||||
'subtitle': null,
|
||||
'icon': Icons.account_tree_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
'editor' => {
|
||||
'title': 'editor',
|
||||
'subtitle': null,
|
||||
'icon': Icons.edit_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
_ => {
|
||||
'title': name,
|
||||
'subtitle': null,
|
||||
'icon': Icons.work_outlined,
|
||||
'actions': {'disabled': false},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
enum QueueCardButtonColor { primary, secondary, outlined }
|
||||
|
||||
class QueueCardButton extends StatelessWidget {
|
||||
const QueueCardButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.color = QueueCardButtonColor.outlined,
|
||||
this.disabled = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final QueueCardButtonColor color;
|
||||
final bool disabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: disabled ? null : onTap,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
decoration: _getDecoration(context),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: _getContentColor(context)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: _getContentColor(context), fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getContentColor(BuildContext context) {
|
||||
if (disabled) {
|
||||
return Colors.grey.shade500;
|
||||
}
|
||||
|
||||
return switch (color) {
|
||||
QueueCardButtonColor.primary => Theme.of(context).colorScheme.onPrimary,
|
||||
QueueCardButtonColor.secondary => Theme.of(context).colorScheme.onSecondary,
|
||||
QueueCardButtonColor.outlined => Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
};
|
||||
}
|
||||
|
||||
BoxDecoration _getDecoration(BuildContext context) {
|
||||
if (disabled) {
|
||||
return BoxDecoration(color: Colors.grey.shade300, borderRadius: const BorderRadius.all(Radius.circular(12)));
|
||||
}
|
||||
|
||||
return switch (color) {
|
||||
QueueCardButtonColor.primary => BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
QueueCardButtonColor.secondary => BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
QueueCardButtonColor.outlined => BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), width: 1.5),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StatisticBox extends StatelessWidget {
|
||||
const StatisticBox({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.colorScheme = StatisticBoxColorScheme.primary,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final StatisticBoxColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isPrimary = colorScheme == StatisticBoxColorScheme.primary;
|
||||
final isTertiary = colorScheme == StatisticBoxColorScheme.tertiary;
|
||||
|
||||
Color bgColor;
|
||||
Color fgColor;
|
||||
|
||||
if (isPrimary) {
|
||||
bgColor = Theme.of(context).colorScheme.primary;
|
||||
fgColor = Theme.of(context).colorScheme.onPrimary;
|
||||
} else if (isTertiary) {
|
||||
bgColor = Theme.of(context).colorScheme.tertiary;
|
||||
fgColor = Theme.of(context).colorScheme.onTertiary;
|
||||
} else {
|
||||
bgColor = Theme.of(context).colorScheme.secondary;
|
||||
fgColor = Theme.of(context).colorScheme.onSecondary;
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: isPrimary ? const Radius.circular(12) : Radius.zero,
|
||||
bottomLeft: isPrimary ? const Radius.circular(12) : Radius.zero,
|
||||
topRight: (isTertiary || !isPrimary) ? const Radius.circular(12) : Radius.zero,
|
||||
bottomRight: (isTertiary || !isPrimary) ? const Radius.circular(12) : Radius.zero,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fgColor.withValues(alpha: 0.7)),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value > 99999 ? _formatNumber(value) : value.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: fgColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 1000000) {
|
||||
return '${(number / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
}
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum StatisticBoxColorScheme { primary, tertiary }
|
||||
Loading…
Reference in New Issue