From da60030032fedb176af272b53dbaf04ea6c5bdae Mon Sep 17 00:00:00 2001 From: Lauritz Tieste Date: Sun, 24 May 2026 15:16:59 +0200 Subject: [PATCH] feat: add admin settings and server statistics pages with routing --- .../pages/settings/admin_settings.page.dart | 70 ++++++ .../settings/server_statistics.page.dart | 209 ++++++++++++++++++ .../lib/providers/server_stats.provider.dart | 12 + mobile/lib/routing/router.dart | 3 + mobile/lib/routing/router.gr.dart | 63 ++++++ .../common/app_bar_dialog/app_bar_dialog.dart | 9 + 6 files changed, 366 insertions(+) create mode 100644 mobile/lib/pages/settings/admin_settings.page.dart create mode 100644 mobile/lib/pages/settings/server_statistics.page.dart create mode 100644 mobile/lib/providers/server_stats.provider.dart 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..6688024823 --- /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/pages/settings/server_statistics.page.dart'; +import 'package:immich_mobile/widgets/settings/settings_card.dart'; + +enum AdminSettingSection { + serverStatistics('server_stats', Icons.bar_chart_outlined, 'total_usage'); + + final String title; + final String subtitle; + final IconData icon; + + Widget get widget => switch (this) { + AdminSettingSection.serverStatistics => const ServerStatisticsPage(), + }; + + 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/pages/settings/server_statistics.page.dart b/mobile/lib/pages/settings/server_statistics.page.dart new file mode 100644 index 0000000000..9d7b0f0bb6 --- /dev/null +++ b/mobile/lib/pages/settings/server_statistics.page.dart @@ -0,0 +1,209 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/server_stats.provider.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart'; +import 'package:openapi/api.dart'; + +class ServerStatisticsPage extends ConsumerWidget { + const ServerStatisticsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final statsAsync = ref.watch(serverStatsProvider); + + return statsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 16), + Text(tr("scaffold_body_error_occurred"), textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton.tonal(onPressed: () => ref.refresh(serverStatsProvider), child: const Text("refresh").tr()), + ], + ), + ), + data: (stats) => ListView( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + children: [ + _ServerStatsOverview(stats: stats), + if (stats.usageByUser.isNotEmpty) _UserUsageList(users: stats.usageByUser), + const SizedBox(height: 24), + ], + ), + ); + } +} + +class _ServerStatsOverview extends StatelessWidget { + const _ServerStatsOverview({required this.stats}); + + final ServerStatsResponseDto stats; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Center(child: Text(tr("total_usage"), style: Theme.of(context).textTheme.titleMedium)), + const SizedBox(height: 12), + IntrinsicHeight( + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8.0, + children: [ + Expanded( + child: EntityCountTile(label: tr("photos"), count: stats.photos, icon: Icons.photo_library), + ), + Expanded( + child: EntityCountTile(label: tr("videos"), count: stats.videos, icon: Icons.video_library), + ), + ], + ), + ), + const SizedBox(height: 8), + StorageTile(usage: stats.usage, usagePhotos: stats.usagePhotos, usageVideos: stats.usageVideos), + ], + ); + } +} + +class _UserUsageList extends StatelessWidget { + const _UserUsageList({required this.users}); + + final List users; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 32), + Center(child: Text(tr("user_usage_detail"), style: Theme.of(context).textTheme.titleMedium)), + const SizedBox(height: 8), + ...users.map((user) => _UserUsageCard(user: user)), + ], + ); + } +} + +class _UserUsageCard extends StatelessWidget { + const _UserUsageCard({required this.user}); + + final UsageByUserDto user; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainerLow, + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + CircleAvatar( + backgroundColor: context.primaryColor.withAlpha(30), + child: Text( + user.userName.isNotEmpty ? user.userName[0].toUpperCase() : "?", + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.userName, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + "${tr("photos")}: ${_formatCount(user.photos)} • ${tr("videos")}: ${_formatCount(user.videos)}", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + "${formatBytes(user.usage)}${user.quotaSizeInBytes != null ? " / ${formatBytes(user.quotaSizeInBytes!)}" : ""}", + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _formatCount(int count) { + if (count >= 1000000) { + return "${(count / 1000000).toStringAsFixed(1)}M"; + } else if (count >= 1000) { + return "${(count / 1000).toStringAsFixed(1)}K"; + } + return count.toString(); + } +} + +class StorageTile extends StatelessWidget { + const StorageTile({super.key, required this.usage, required this.usagePhotos, required this.usageVideos}); + + final int usage; + final int usagePhotos; + final int usageVideos; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.storage, color: context.primaryColor, size: 14), + const SizedBox(width: 4), + Text( + tr("storage"), + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w500), + ), + ], + ), + const SizedBox(height: 8), + Text(formatBytes(usage), style: const TextStyle(fontSize: 18, fontFamily: 'GoogleSansCode')), + const SizedBox(height: 4), + Text( + "${tr("photos")}: ${formatBytes(usagePhotos)} • ${tr("videos")}: ${formatBytes(usageVideos)}", + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/server_stats.provider.dart b/mobile/lib/providers/server_stats.provider.dart new file mode 100644 index 0000000000..e2f21c9806 --- /dev/null +++ b/mobile/lib/providers/server_stats.provider.dart @@ -0,0 +1,12 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final serverStatsProvider = FutureProvider.autoDispose((ref) async { + final apiService = ref.watch(apiServiceProvider); + final stats = await apiService.serverInfoApi.getServerStatistics(); + if (stats == null) { + throw Exception('Failed to load server statistics'); + } + return stats; +}); 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/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(), ],