Files
ignis_app/lib/screens/api_keys_screen.dart
2026-05-01 09:47:08 +07:00

480 lines
15 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';
class ApiKeysScreen extends ConsumerStatefulWidget {
const ApiKeysScreen({super.key});
@override
ConsumerState<ApiKeysScreen> createState() => _ApiKeysScreenState();
}
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
final Set<String> _busyKeys = <String>{};
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!,
onDismiss: () => setState(() => _lastCreatedKey = null),
),
Expanded(child: _buildContent(keysState, keys)),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: _showCreateDialog,
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 keyData = keys[index - statusHeaderCount];
final busy = _busyKeys.contains(keyData.key);
return _ApiKeyCard(
data: keyData,
busy: busy,
onRevoke: busy ? null : () => _revokeKey(keyData),
onActivate: busy ? null : () => _activateKey(keyData),
);
},
),
);
}
Future<void> _showCreateDialog() async {
final formKey = GlobalKey<FormState>();
final nameCtrl = TextEditingController();
var isAdmin = false;
var isCreating = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Новый API-ключ'),
content: Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Имя ключа',
hintText: 'Например: Гость',
),
autofocus: true,
validator: (value) {
final normalized = value?.trim() ?? '';
if (normalized.isEmpty) {
return 'Укажите имя ключа';
}
if (normalized.length < 2) {
return 'Слишком короткое имя';
}
return null;
},
),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Дать права администратора'),
subtitle: const Text(
'Используйте только для доверенных людей',
),
value: isAdmin,
activeThumbColor: Colors.deepOrange,
onChanged: isCreating
? null
: (value) => setDialogState(() => isAdmin = value),
contentPadding: EdgeInsets.zero,
),
],
),
),
actions: [
TextButton(
onPressed: isCreating ? null : () => Navigator.of(ctx).pop(),
child: const Text('Отмена'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepOrange,
),
onPressed: isCreating
? null
: () async {
if (!formKey.currentState!.validate()) {
return;
}
setDialogState(() => isCreating = true);
try {
final key = await ref
.read(apiKeysProvider.notifier)
.create(nameCtrl.text.trim(), isAdmin: isAdmin);
if (!mounted) return;
setState(() => _lastCreatedKey = key);
if (ctx.mounted) {
Navigator.of(ctx).pop();
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ создан'),
duration: Duration(seconds: 1),
),
);
} catch (e) {
if (!ctx.mounted) return;
setDialogState(() => isCreating = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Ошибка создания ключа: ${describeLoadError(e)}',
),
),
);
}
},
child: isCreating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Создать'),
),
],
),
),
);
nameCtrl.dispose();
}
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) {
return;
}
setState(() => _busyKeys.add(data.key));
try {
await ref.read(apiKeysProvider.notifier).revoke(data.key);
if (!mounted) return;
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)}')),
);
} finally {
if (mounted) {
setState(() => _busyKeys.remove(data.key));
}
}
}
Future<void> _activateKey(ApiKeyInfo data) async {
setState(() => _busyKeys.add(data.key));
try {
await ref.read(apiKeysProvider.notifier).activate(data.key);
if (!mounted) return;
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)}'),
),
);
} finally {
if (mounted) {
setState(() => _busyKeys.remove(data.key));
}
}
}
}
class _LastCreatedKeyBanner extends StatelessWidget {
final String keyValue;
final VoidCallback onDismiss;
const _LastCreatedKeyBanner({
required this.keyValue,
required this.onDismiss,
});
@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: [
Row(
children: [
const Expanded(
child: Text(
'Новый ключ создан. Скопируйте его сейчас, потом приложение уже не покажет полный токен.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
),
IconButton(
onPressed: onDismiss,
icon: const Icon(Icons.close, size: 18),
tooltip: 'Скрыть',
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
keyValue,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.white70,
),
maxLines: 3,
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),
),
);
},
),
],
),
],
),
);
}
}
class _ApiKeyCard extends StatelessWidget {
final ApiKeyInfo data;
final bool busy;
final VoidCallback? onRevoke;
final VoidCallback? onActivate;
const _ApiKeyCard({
required this.data,
required this.busy,
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: [
Expanded(
child: 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: busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.deepOrange,
),
)
: IconButton(
icon: Icon(
data.isActive ? Icons.block : Icons.check_circle_outline,
size: 20,
color: data.isActive ? Colors.redAccent : Colors.green,
),
tooltip: data.isActive ? 'Отозвать' : 'Активировать',
onPressed: data.isActive ? onRevoke : onActivate,
),
),
);
}
}