pull/28590/merge
Lauritz Tieste 2026-06-03 16:43:14 +02:00 committed by GitHub
commit 99bd94864f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 366 additions and 0 deletions

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/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<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,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<UsageByUserDto> 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),
),
],
),
);
}
}

View File

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

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

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