Files
ignis_app/lib/screens/api_keys_screen.dart
2026-04-23 20:57:15 +07:00

398 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../models/api_key_info.dart';
import '../providers/providers.dart';
import '../widgets/load_error_view.dart';
/// Экран управления гостевыми API-ключами.
/// Доступен только администраторам.
class ApiKeysScreen extends ConsumerStatefulWidget {
const ApiKeysScreen({super.key});
@override
ConsumerState<ApiKeysScreen> createState() => _ApiKeysScreenState();
}
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
String? _lastCreatedKey;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ref.read(apiKeysProvider.notifier).load();
}
@override
Widget build(BuildContext context) {
final keysState = ref.watch(apiKeysProvider);
final keys = keysState.data;
return Scaffold(
appBar: AppBar(title: const Text('API-КЛЮЧИ')),
body: Column(
children: [
if (_lastCreatedKey != null)
_LastCreatedKeyBanner(keyValue: _lastCreatedKey!),
Expanded(child: _buildContent(keysState, keys)),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildContent(
LoadState<List<ApiKeyInfo>> keysState,
List<ApiKeyInfo> 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];
return _ApiKeyCard(
data: key,
onRevoke: () => _revokeKey(key),
onActivate: () => _activateKey(key),
);
},
),
);
}
void _showCreateDialog(BuildContext context) {
final nameCtrl = TextEditingController();
bool isAdmin = false;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Новый API-ключ'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Имя ключа',
hintText: 'Например: "Гость"',
),
autofocus: true,
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Администратор'),
value: isAdmin,
activeThumbColor: Colors.deepOrange,
onChanged: (v) => setDialogState(() => isAdmin = v),
contentPadding: EdgeInsets.zero,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Отмена'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
),
onPressed: () async {
final name = nameCtrl.text.trim();
if (name.isEmpty) return;
Navigator.of(ctx).pop();
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('Создать'),
),
],
),
),
);
}
Future<void> _revokeKey(ApiKeyInfo data) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Отозвать ключ?'),
content: Text('Отозвать "${data.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text(
'Отозвать',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (confirmed == true) {
try {
await ref.read(apiKeysProvider.notifier).revoke(data.key);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка отзыва ключа: ${describeLoadError(e)}'),
),
);
}
}
}
Future<void> _activateKey(ApiKeyInfo data) async {
try {
await ref.read(apiKeysProvider.notifier).activate(data.key);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ активирован'),
duration: Duration(seconds: 1),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
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 ApiKeyInfo data;
final VoidCallback onRevoke;
final VoidCallback onActivate;
const _ApiKeyCard({
required this.data,
required this.onRevoke,
required this.onActivate,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.vpn_key,
color: data.isActive
? (data.isAdmin ? Colors.amber : Colors.deepOrange)
: Colors.white24,
),
title: Row(
children: [
Text(
data.name,
style: TextStyle(
fontWeight: FontWeight.bold,
color: data.isActive ? Colors.white : Colors.white38,
),
),
if (data.isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'admin',
style: TextStyle(fontSize: 10, color: Colors.amber),
),
),
],
if (!data.isActive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.redAccent.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'отозван',
style: TextStyle(fontSize: 10, color: Colors.redAccent),
),
),
],
],
),
subtitle: data.formattedCreatedAt.isNotEmpty
? Text(
'Создан: ${data.formattedCreatedAt}',
style: const TextStyle(fontSize: 11, color: Colors.white30),
)
: null,
trailing: data.isActive
? IconButton(
icon: const Icon(
Icons.block,
size: 20,
color: Colors.redAccent,
),
tooltip: 'Отозвать',
onPressed: onRevoke,
)
: IconButton(
icon: const Icon(
Icons.check_circle_outline,
size: 20,
color: Colors.green,
),
tooltip: 'Активировать',
onPressed: onActivate,
),
),
);
}
}