Files
ignis_app/lib/screens/api_keys_screen.dart
2026-04-02 23:51:28 +07:00

335 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 '../providers/providers.dart';
/// Экран управления гостевыми API-ключами.
/// Доступен только администраторам.
class ApiKeysScreen extends ConsumerStatefulWidget {
const ApiKeysScreen({super.key});
@override
ConsumerState<ApiKeysScreen> createState() => _ApiKeysScreenState();
}
class _ApiKeysScreenState extends ConsumerState<ApiKeysScreen> {
bool _loading = true;
String? _lastCreatedKey;
@override
void initState() {
super.initState();
_load();
}
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);
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.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepOrange.withOpacity(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),
);
},
),
),
),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.deepOrange,
onPressed: () => _showCreateDialog(context),
child: const Icon(Icons.add),
),
);
}
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,
activeColor: 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 key = await ref
.read(apiKeysProvider.notifier)
.create(name, isAdmin: isAdmin);
if (key != null && mounted) {
setState(() => _lastCreatedKey = key);
}
},
child: const Text('Создать'),
),
],
),
),
);
}
Future<void> _revokeKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
final name = (data['name'] ?? '').toString();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Отозвать ключ?'),
content: Text('Отозвать "$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) {
await ref.read(apiKeysProvider.notifier).revoke(key);
}
}
Future<void> _activateKey(Map<String, dynamic> data) async {
final key = (data['key'] ?? data['token'] ?? '').toString();
await ref.read(apiKeysProvider.notifier).activate(key);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ активирован'),
duration: Duration(seconds: 1),
),
);
}
}
}
/// Карточка одного API-ключа
class _ApiKeyCard extends StatelessWidget {
final Map<String, dynamic> data;
final VoidCallback onRevoke;
final VoidCallback onActivate;
const _ApiKeyCard({
required this.data,
required this.onRevoke,
required this.onActivate,
});
@override
Widget build(BuildContext context) {
final name = (data['name'] ?? 'Без имени').toString();
final isAdmin = data['is_admin'] == true;
final isActive = data['is_active'] ?? data['active'] ?? true;
final createdAt = data['created_at'] ?? '';
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
Icons.vpn_key,
color: isActive
? (isAdmin ? Colors.amber : Colors.deepOrange)
: Colors.white24,
),
title: Row(
children: [
Text(
name,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isActive ? Colors.white : Colors.white38,
),
),
if (isAdmin) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'admin',
style: TextStyle(fontSize: 10, color: Colors.amber),
),
),
],
if (!isActive) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.redAccent.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'отозван',
style: TextStyle(fontSize: 10, color: Colors.redAccent),
),
),
],
],
),
subtitle: createdAt.toString().isNotEmpty
? Text(
'Создан: ${_formatDate(createdAt.toString())}',
style: const TextStyle(fontSize: 11, color: Colors.white30),
)
: null,
trailing: 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,
),
),
);
}
String _formatDate(String iso) {
try {
final d = DateTime.parse(iso);
final pad = (int n) => n.toString().padLeft(2, '0');
return '${pad(d.day)}.${pad(d.month)}.${d.year}';
} catch (_) {
return iso;
}
}
}