feat: add admin settings and server statistics pages with routing
parent
fd7ddfef54
commit
da60030032
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue