pull/28484/merge
Lauritz Tieste 2026-06-03 16:40:39 +02:00 committed by GitHub
commit a08b1abca6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 980 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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