feat: surface admin load errors

This commit is contained in:
Artem Kokos
2026-04-23 20:11:37 +07:00
parent 5d2d0ac4a7
commit 90a86e932d
4 changed files with 516 additions and 191 deletions

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/error_message.dart';
import '../app/load_state.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления гостевыми API-ключами.
/// Доступен только администраторам.
@@ -13,7 +16,6 @@ class ApiKeysScreen extends ConsumerStatefulWidget {
}
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
bool _loading = true;
String? _lastCreatedKey;
@override
@@ -24,110 +26,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
Future<void> _load() async {
await ref.read(apiKeysProvider.notifier).load();
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final keys = ref.watch(apiKeysProvider);
final keysState = ref.watch(apiKeysProvider);
final keys = keysState.data;
return Scaffold(
appBar: AppBar(title: const Text('API-КЛЮЧИ')),
body: _loading
? const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
)
: Column(
children: [
// ─── Последний созданный ключ (для копирования) ───
if (_lastCreatedKey != null)
Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.deepOrange.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Новый ключ создан! Скопируйте его сейчас:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
_lastCreatedKey!,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(
ClipboardData(text: _lastCreatedKey!),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ скопирован'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
],
),
),
// ─── Список ключей ───
Expanded(
child: keys.isEmpty
? const Center(
child: Text(
'Нет гостевых ключей',
style: TextStyle(color: Colors.white54),
),
)
: RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: keys.length,
itemBuilder: (context, index) {
final k = keys[index];
final map = k is Map
? Map<String, dynamic>.from(k)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
onRevoke: () => _revokeKey(map),
onActivate: () => _activateKey(map),
);
},
),
),
),
],
),
body: Column(
children: [
if (_lastCreatedKey != null)
_LastCreatedKeyBanner(keyValue: _lastCreatedKey!),
Expanded(child: _buildContent(keysState, keys)),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context),
@@ -136,6 +50,72 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
);
}
Widget _buildContent(LoadState<List<dynamic>> keysState, List<dynamic> keys) {
if ((keysState.isIdle || keysState.isLoading) && keys.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.deepOrange),
);
}
if (keysState.hasError && keys.isEmpty) {
return LoadErrorView(
title: 'Не удалось загрузить API-ключи',
message: keysState.errorMessage,
icon: Icons.vpn_key,
onRetry: _load,
);
}
if (keys.isEmpty) {
return const Center(
child: Text(
'Нет гостевых ключей',
style: TextStyle(color: Colors.white54),
),
);
}
final hasStatusHeader = keysState.isLoading || keysState.hasError;
final statusHeaderCount = hasStatusHeader ? 1 : 0;
return RefreshIndicator(
color: Colors.deepOrange,
onRefresh: _load,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(12),
itemCount: keys.length + statusHeaderCount,
itemBuilder: (context, index) {
if (hasStatusHeader && index == 0) {
if (keysState.isLoading) {
return const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(color: Colors.deepOrange),
);
}
return LoadErrorBanner(
title: 'Не удалось обновить API-ключи',
message: keysState.errorMessage,
onRetry: _load,
);
}
final keyIndex = index - statusHeaderCount;
final key = keys[keyIndex];
final map = key is Map
? Map<String, dynamic>.from(key)
: <String, dynamic>{};
return _ApiKeyCard(
data: map,
onRevoke: () => _revokeKey(map),
onActivate: () => _activateKey(map),
);
},
),
);
}
void _showCreateDialog(BuildContext context) {
final nameCtrl = TextEditingController();
bool isAdmin = false;
@@ -179,11 +159,22 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final name = nameCtrl.text.trim();
if (name.isEmpty) return;
Navigator.of(ctx).pop();
final key = await ref
.read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin);
if (key != null && mounted) {
final messenger = ScaffoldMessenger.of(context);
try {
final key = await ref
.read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin);
if (!mounted) return;
setState(() => _lastCreatedKey = key);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Ошибка создания ключа: ${describeLoadError(e)}',
),
),
);
}
},
child: const Text('Создать'),
@@ -218,24 +209,103 @@ class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
),
);
if (confirmed == true) {
await ref.read(apiKeysProvider.notifier).revoke(key);
try {
await ref.read(apiKeysProvider.notifier).revoke(key);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'),
),
);
}
}
}
Future<void> _activateKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
await ref.read(apiKeysProvider.notifier).activate(key);
if (mounted) {
try {
await ref.read(apiKeysProvider.notifier).activate(key);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ активирован'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ активирован'),
duration: Duration(seconds: 1),
SnackBar(
content: Text('Ошибка активации ключа: ${describeLoadError(e)}'),
),
);
}
}
}
class _LastCreatedKeyBanner extends StatelessWidget {
final String keyValue;
const _LastCreatedKeyBanner({required this.keyValue});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Новый ключ создан! Скопируйте его сейчас:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
keyValue,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: keyValue));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ скопирован'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
],
),
);
}
}
/// Карточка одного API-ключа
class _ApiKeyCard extends StatelessWidget {
final Map<String, dynamic> data;