diff --git a/mobile/lib/models/queues/queue_response.model.dart b/mobile/lib/models/queues/queue_response.model.dart new file mode 100644 index 0000000000..3f2fc01591 --- /dev/null +++ b/mobile/lib/models/queues/queue_response.model.dart @@ -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), + ); +} diff --git a/mobile/lib/pages/settings/admin_settings.page.dart b/mobile/lib/pages/settings/admin_settings.page.dart new file mode 100644 index 0000000000..33429cf6ec --- /dev/null +++ b/mobile/lib/pages/settings/admin_settings.page.dart @@ -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 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, + ); + } +} diff --git a/mobile/lib/providers/queues.provider.dart b/mobile/lib/providers/queues.provider.dart new file mode 100644 index 0000000000..fbd94ba688 --- /dev/null +++ b/mobile/lib/providers/queues.provider.dart @@ -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>((ref) { + final repository = ref.watch(queuesApiRepositoryProvider); + return QueuesNotifier(repository); +}); + +final queueByNameProvider = Provider.family((ref, name) { + final queues = ref.watch(queuesProvider); + for (final queue in queues) { + if (queue.name == name) { + return queue; + } + } + return null; +}); + +class QueuesNotifier extends StateNotifier> { + QueuesNotifier(this._repository) : super([]) { + _refresh(); + _timer = Timer.periodic(const Duration(seconds: 3), (_) => _refresh()); + } + + final QueuesApiRepository _repository; + Timer? _timer; + bool _disposed = false; + + Future _refresh() async { + if (_disposed) { + return; + } + + try { + final queues = await _repository.getAll(); + if (!_disposed) { + state = queues; + } + } catch (e) { + dPrint(() => 'QueuesNotifier error: $e'); + } + } + + Future pauseQueue(QueueName name) async { + try { + final queue = await _repository.updateQueue(name, true); + _updateQueueInState(queue); + } catch (e) { + dPrint(() => 'pauseQueue error: $e'); + } + } + + Future resumeQueue(QueueName name) async { + try { + final queue = await _repository.updateQueue(name, false); + _updateQueueInState(queue); + } catch (e) { + dPrint(() => 'resumeQueue error: $e'); + } + } + + Future 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 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.from(state); + newState[index] = updatedQueue; + state = newState; + } + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/repositories/queues_api.repository.dart b/mobile/lib/repositories/queues_api.repository.dart new file mode 100644 index 0000000000..a1bfe40af5 --- /dev/null +++ b/mobile/lib/repositories/queues_api.repository.dart @@ -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> getAll() async { + final response = await checkNull(_api.getQueues()); + return response.map((dto) => QueueResponse.fromDto(dto)).toList(); + } + + Future updateQueue(QueueName name, bool isPaused) async { + final response = await checkNull(_api.updateQueue(name, QueueUpdateDto(isPaused: isPaused))); + return QueueResponse.fromDto(response); + } + + Future 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 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); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b39a568e26..c50eb60500 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a4b538d789..4064897082 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -10,6 +10,69 @@ part of 'router.dart'; +/// generated route for +/// [AdminSettingsPage] +class AdminSettingsRoute extends PageRouteInfo { + const AdminSettingsRoute({List? 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 { + AdminSettingsSubRoute({ + required AdminSettingSection section, + Key? key, + List? 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(); + 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 { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index a0828927ce..65b01b8fd6 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -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 resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index e77bc1869e..a70629d5ef 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -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(), ], diff --git a/mobile/lib/widgets/settings/job_queue_settings.dart b/mobile/lib/widgets/settings/job_queue_settings.dart new file mode 100644 index 0000000000..4e5016ab57 --- /dev/null +++ b/mobile/lib/widgets/settings/job_queue_settings.dart @@ -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; + + 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 _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}, + }, + }; + } +} diff --git a/mobile/lib/widgets/settings/queue_card_button.dart b/mobile/lib/widgets/settings/queue_card_button.dart new file mode 100644 index 0000000000..1f277e86cf --- /dev/null +++ b/mobile/lib/widgets/settings/queue_card_button.dart @@ -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 }