Waaaay big enchancements

This commit is contained in:
Artem Kokos
2026-04-02 23:51:28 +07:00
parent 6221fbcc71
commit 5e09f41747
14 changed files with 1308 additions and 111 deletions

View File

@@ -0,0 +1,334 @@
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;
}
}
}