feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/client.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/exceptions.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/admin.dart';
import '../../../models/tariff.dart';
import '../../../models/auth.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../widgets/empty_state.dart';
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 7, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Админ-панель'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(text: 'Статистика'),
Tab(text: 'Пользователи'),
Tab(text: 'Агенты'),
Tab(text: 'Логи'),
Tab(text: 'Тарифы'),
Tab(text: 'Бот'),
Tab(text: 'Сервисы'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_StatsTab(),
_UsersTab(),
_AgentsTab(),
_LogsTab(),
_TariffsTab(),
_BotTab(),
_ServicesTab(),
],
),
);
}
}
class _StatsTab extends ConsumerWidget {
const _StatsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<AdminStats>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminStats);
return AdminStats.fromJson(resp.data as Map<String, dynamic>);
},
builder: (stats) => GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_StatCard(label: 'Пользователи', value: '${stats.totalUsers}', icon: Icons.people),
_StatCard(label: 'Идеи', value: '${stats.totalIdeas}', icon: Icons.lightbulb),
_StatCard(label: 'Голосовые сессии', value: '${stats.totalVoiceSessions}', icon: Icons.mic),
_StatCard(label: 'Раб. пространства', value: '${stats.totalWorkspaces}', icon: Icons.workspaces),
_StatCard(label: 'Активные агенты', value: '${stats.activeAgents}', icon: Icons.smart_toy),
_StatCard(label: 'Уведомления', value: '${stats.totalNotifications}', icon: Icons.notifications),
],
),
);
}
}
class _StatCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatCard({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: VoIdeaColors.primary, size: 28),
const SizedBox(height: 8),
Text(value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
)),
Text(label,
style: const TextStyle(fontSize: 12, color: VoIdeaColors.muted)),
],
),
),
);
}
}
class _UsersTab extends ConsumerWidget {
const _UsersTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<User>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminUsers);
return (resp.data as List<dynamic>)
.map((e) => User.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (users) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: users.length,
itemBuilder: (_, i) {
final u = users[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: VoIdeaColors.primary.withOpacity(0.1),
child: Text(u.name.isNotEmpty ? u.name[0].toUpperCase() : '?',
style: const TextStyle(color: VoIdeaColors.primary)),
),
title: Text(u.name),
subtitle: Text('${u.email} · ${DateFormat('d MMM yyyy').format(u.createdAt)}',
style: const TextStyle(fontSize: 12)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (u.isAdmin)
const Chip(label: Text('Admin', style: TextStyle(fontSize: 10))),
if (u.isTwoFactorEnabled)
const Padding(
padding: EdgeInsets.only(left: 4),
child: Icon(Icons.security, size: 16, color: VoIdeaColors.success),
),
],
),
),
);
},
),
);
}
}
class _AgentsTab extends ConsumerWidget {
const _AgentsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<AgentConfig>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminAgents);
return (resp.data as List<dynamic>)
.map((e) => AgentConfig.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (agents) => Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _runAllAgents(ref),
icon: const Icon(Icons.play_arrow),
label: const Text('Запустить всех'),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: agents.length,
itemBuilder: (_, i) {
final agent = agents[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
leading: Icon(
agent.isActive ? Icons.smart_toy : Icons.smart_toy_outlined,
color: agent.isActive ? VoIdeaColors.success : VoIdeaColors.muted,
),
title: Text(agent.name),
subtitle: Text(agent.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_arrow, size: 20),
onPressed: () => _runAgent(ref, agent.name),
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Расписание: ${agent.schedule}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Активен: ${agent.isActive ? "Да" : "Нет"}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
Text('Конфиг: ${agent.config.toString()}',
style: const TextStyle(fontSize: 12)),
],
),
),
],
),
);
},
),
),
],
),
);
}
void _runAgent(WidgetRef ref, String name) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentRun(name));
} catch (_) {}
}
void _runAllAgents(WidgetRef ref) async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.adminAgentsRunAll);
} catch (_) {}
}
}
class _LogsTab extends ConsumerWidget {
const _LogsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<LogEntry>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminLogs);
return (resp.data as List<dynamic>)
.map((e) => LogEntry.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (logs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: logs.length,
itemBuilder: (_, i) {
final log = logs[i];
final color = log.level == 'ERROR'
? VoIdeaColors.error
: log.level == 'WARNING'
? VoIdeaColors.warning
: VoIdeaColors.muted;
return Card(
margin: const EdgeInsets.only(bottom: 4),
child: ListTile(
dense: true,
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
title: Text(log.message,
maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
subtitle: Row(
children: [
Text(log.level, style: TextStyle(fontSize: 11, color: color)),
const SizedBox(width: 8),
if (log.source != null)
Text(log.source!,
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
const Spacer(),
Text(DateFormat('HH:mm:ss').format(log.createdAt),
style: const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
],
),
),
);
},
),
);
}
}
class _TariffsTab extends ConsumerWidget {
const _TariffsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<Tariff>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminTariffs);
return (resp.data as List<dynamic>)
.map((e) => Tariff.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (tariffs) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: tariffs.length,
itemBuilder: (_, i) {
final t = tariffs[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(t.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const Spacer(),
Text('${t.price} ${t.currency}/мес',
style: TextStyle(
color: VoIdeaColors.primary,
fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
...t.features.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(Icons.check, size: 14, color: VoIdeaColors.success),
const SizedBox(width: 8),
Text('${e.key}: ${e.value}',
style: const TextStyle(fontSize: 13)),
],
),
)),
],
),
),
);
},
),
);
}
}
class _BotTab extends ConsumerWidget {
const _BotTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<List<BotCommand>>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminBotCommands);
return (resp.data as List<dynamic>)
.map((e) => BotCommand.fromJson(e as Map<String, dynamic>))
.toList();
},
builder: (commands) => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: commands.length,
itemBuilder: (_, i) {
final cmd = commands[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
cmd.isEnabled ? Icons.check_circle : Icons.cancel,
color: cmd.isEnabled ? VoIdeaColors.success : VoIdeaColors.error,
),
title: Text('/${cmd.command}'),
subtitle: Text(cmd.description, maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing: Switch(
value: cmd.isEnabled,
onChanged: (v) => _toggleCommand(ref, cmd.id, v),
),
),
);
},
),
);
}
void _toggleCommand(WidgetRef ref, String id, bool enabled) async {
try {
final api = ref.read(apiClientProvider);
await api.patch('${Endpoints.adminBotCommands}/$id', data: {
'is_enabled': enabled,
});
} catch (_) {}
}
}
class _ServicesTab extends ConsumerWidget {
const _ServicesTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return _FutureBuilder<HealthStatus>(
future: () async {
final api = ref.read(apiClientProvider);
final resp = await api.get(Endpoints.adminHealth);
return HealthStatus.fromJson(resp.data as Map<String, dynamic>);
},
builder: (health) => ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
health.status == 'ok'
? Icons.check_circle
: Icons.error,
color: health.status == 'ok'
? VoIdeaColors.success
: VoIdeaColors.error,
),
const SizedBox(width: 8),
Text('Статус: ${health.status}',
style: Theme.of(context).textTheme.titleMedium),
],
),
const SizedBox(height: 12),
Text('Версия: ${health.version}',
style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4),
Text('Uptime: ${health.uptimeSeconds.toStringAsFixed(0)}с',
style: const TextStyle(fontSize: 13)),
],
),
),
),
const SizedBox(height: 16),
const Text('Сервисы',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 8),
...health.services.entries.map((e) => Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
e.value == 'ok' || e.value == true
? Icons.check_circle
: Icons.error,
color: e.value == 'ok' || e.value == true
? VoIdeaColors.success
: VoIdeaColors.error,
size: 20,
),
title: Text(e.key),
subtitle: Text('${e.value}'),
),
)),
],
),
);
}
}
class _FutureBuilder<T> extends StatelessWidget {
final Future<T> Function() future;
final Widget Function(T data) builder;
const _FutureBuilder({required this.future, required this.builder});
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future(),
builder: (ctx, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingIndicator();
}
if (snapshot.hasError) {
return ErrorDisplay(message: snapshot.error.toString());
}
if (!snapshot.hasData) {
return const EmptyState(
icon: Icons.info_outline, title: 'Нет данных');
}
return builder(snapshot.data!);
},
);
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/auth_provider.dart';
import '../../../core/utils/validators.dart';
import '../../../core/theme/app_theme.dart';
class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({super.key});
@override
ConsumerState<ForgotPasswordScreen> createState() =>
_ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _sent = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authNotifier = ref.read(authProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Восстановление пароля')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
const Icon(
Icons.lock_reset,
size: 64,
color: VoIdeaColors.primary,
),
const SizedBox(height: 24),
if (!_sent) ...[
Text(
'Введите email, привязанный к аккаунту',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: Validators.email,
),
const SizedBox(height: 24),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style: const TextStyle(color: VoIdeaColors.error),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: authState.status == AuthStatus.loading
? null
: () {
if (_formKey.currentState!.validate()) {
authNotifier
.forgotPassword(_emailController.text.trim())
.then((_) => setState(() => _sent = true));
}
},
child: const Text('Отправить'),
),
] else ...[
const Icon(
Icons.check_circle,
size: 64,
color: VoIdeaColors.success,
),
const SizedBox(height: 16),
Text(
'Письмо отправлено',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Проверьте почту и перейдите по ссылке для сброса пароля',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/auth/login'),
child: const Text('Вернуться ко входу'),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/validators.dart';
import '../../../core/constants/app_constants.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authNotifier = ref.read(authProvider.notifier);
ref.listen(authProvider, (_, state) {
if (state.status == AuthStatus.authenticated) {
context.go('/');
}
if (state.needsTwoFactor) {
context.push('/auth/2fa');
}
});
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
Icon(
Icons.lightbulb_outline,
size: 64,
color: VoIdeaColors.primary,
).animate().fadeIn().scale(),
const SizedBox(height: 16),
Text(
AppConstants.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: VoIdeaColors.primary,
),
).animate().fadeIn(delay: 100.ms),
const SizedBox(height: 8),
Text(
'Войдите в аккаунт',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: VoIdeaColors.muted,
),
).animate().fadeIn(delay: 200.ms),
const SizedBox(height: 48),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: Validators.email,
).animate().fadeIn(delay: 300.ms).slideX(),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Пароль',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: Validators.password,
).animate().fadeIn(delay: 400.ms).slideX(),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => context.push('/auth/forgot-password'),
child: const Text('Забыли пароль?'),
),
),
const SizedBox(height: 24),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style:
const TextStyle(color: VoIdeaColors.error),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: authState.status == AuthStatus.loading
? null
: () {
if (_formKey.currentState!.validate()) {
authNotifier.login(
_emailController.text.trim(),
_passwordController.text,
);
}
},
child: authState.status == AuthStatus.loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Войти'),
).animate().fadeIn(delay: 500.ms),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () {
_emailController.text = AppConstants.demoEmail;
_passwordController.text = AppConstants.demoPassword;
authNotifier.login(AppConstants.demoEmail, AppConstants.demoPassword);
},
icon: const Icon(Icons.play_circle_outline, size: 18),
label: const Text('Демо-доступ'),
).animate().fadeIn(delay: 550.ms),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.push('/auth/register'),
child: const Text('Создать аккаунт'),
).animate().fadeIn(delay: 600.ms),
const SizedBox(height: 32),
const Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('или'),
),
Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _oauthLogin('google'),
icon: const Icon(Icons.g_mobiledata),
label: const Text('Google'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _oauthLogin('yandex'),
icon: const Icon(Icons.cloud),
label: const Text('Яндекс'),
),
],
),
),
),
),
);
}
void _oauthLogin(String provider) {
ref.read(authProvider.notifier).getOAuthUrl(provider).then((url) {
if (url != null && mounted) {
context.push('/auth/oauth?provider=$provider&url=$url');
}
});
}
}
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/auth_provider.dart';
import '../../../core/utils/validators.dart';
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authNotifier = ref.read(authProvider.notifier);
ref.listen(authProvider, (_, state) {
if (state.status == AuthStatus.authenticated) {
context.go('/');
}
});
return Scaffold(
appBar: AppBar(title: const Text('Регистрация')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Имя',
prefixIcon: Icon(Icons.person_outline),
),
validator: (v) => Validators.required(v, 'Имя'),
).animate().fadeIn().slideX(),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: Validators.email,
).animate().fadeIn(delay: 100.ms).slideX(),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Пароль',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: Validators.password,
).animate().fadeIn(delay: 200.ms).slideX(),
const SizedBox(height: 16),
TextFormField(
controller: _confirmController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Подтвердите пароль',
prefixIcon: Icon(Icons.lock_outlined),
),
validator: (v) =>
Validators.confirmPassword(v, _passwordController.text),
).animate().fadeIn(delay: 300.ms).slideX(),
const SizedBox(height: 32),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style: const TextStyle(color: Color(0xFFEF4444)),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: authState.status == AuthStatus.loading
? null
: () {
if (_formKey.currentState!.validate()) {
authNotifier.register(
_emailController.text.trim(),
_passwordController.text,
_nameController.text.trim(),
);
}
},
child: authState.status == AuthStatus.loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Зарегистрироваться'),
).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.pop(),
child: const Text('Уже есть аккаунт? Войти'),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/auth_provider.dart';
import '../../../core/utils/validators.dart';
import '../../../core/theme/app_theme.dart';
class ResetPasswordScreen extends ConsumerStatefulWidget {
const ResetPasswordScreen({super.key});
@override
ConsumerState<ResetPasswordScreen> createState() =>
_ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _tokenController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _success = false;
@override
void dispose() {
_tokenController.dispose();
_passwordController.dispose();
_confirmController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authNotifier = ref.read(authProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Новый пароль')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
const Icon(
Icons.lock_reset,
size: 64,
color: VoIdeaColors.primary,
),
const SizedBox(height: 24),
if (!_success) ...[
Text(
'Введите код из письма и новый пароль',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
TextFormField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Код из письма',
prefixIcon: Icon(Icons.vpn_key_outlined),
),
validator: (v) => Validators.required(v, 'Код'),
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Новый пароль',
prefixIcon: Icon(Icons.lock_outlined),
),
validator: Validators.password,
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Подтвердите пароль',
prefixIcon: Icon(Icons.lock_outlined),
),
validator: (v) =>
Validators.confirmPassword(v, _passwordController.text),
),
const SizedBox(height: 24),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style: const TextStyle(color: VoIdeaColors.error),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: authState.status == AuthStatus.loading
? null
: () {
if (_formKey.currentState!.validate()) {
authNotifier
.resetPassword(
_tokenController.text.trim(),
_passwordController.text,
)
.then((_) => setState(() => _success = true));
}
},
child: const Text('Сохранить'),
),
] else ...[
const Icon(
Icons.check_circle,
size: 64,
color: VoIdeaColors.success,
),
const SizedBox(height: 16),
Text(
'Пароль изменён',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.go('/auth/login'),
child: const Text('Войти'),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
class TwoFactorScreen extends ConsumerStatefulWidget {
const TwoFactorScreen({super.key});
@override
ConsumerState<TwoFactorScreen> createState() => _TwoFactorScreenState();
}
class _TwoFactorScreenState extends ConsumerState<TwoFactorScreen> {
final _codeController = TextEditingController();
@override
void dispose() {
_codeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final authNotifier = ref.read(authProvider.notifier);
ref.listen(authProvider, (_, state) {
if (state.status == AuthStatus.authenticated) {
context.go('/');
}
});
return Scaffold(
appBar: AppBar(title: const Text('Двухфакторная аутентификация')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
const Icon(
Icons.security,
size: 64,
color: VoIdeaColors.primary,
),
const SizedBox(height: 24),
Text(
'Введите код из приложения аутентификации',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
TextField(
controller: _codeController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '000000',
counterText: '',
helperText: '6-значный код',
),
maxLength: 6,
style: const TextStyle(fontSize: 32, letterSpacing: 8),
),
const SizedBox(height: 32),
if (authState.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
authState.error!,
style: const TextStyle(color: VoIdeaColors.error),
textAlign: TextAlign.center,
),
),
ElevatedButton(
onPressed: authState.status == AuthStatus.loading
? null
: () {
if (_codeController.text.length == 6) {
authNotifier.verifyTwoFactor(_codeController.text);
}
},
child: authState.status == AuthStatus.loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Подтвердить'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
authNotifier.logout();
context.go('/auth/login');
},
child: const Text('Назад ко входу'),
),
],
),
),
);
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../providers/ideas_provider.dart';
import '../../providers/workspace_provider.dart';
import '../../core/theme/app_theme.dart';
import '../../widgets/loading_indicator.dart';
import '../../widgets/error_display.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/funnel_status_badge.dart';
import '../../models/idea.dart';
import '../../core/constants/app_constants.dart';
import '../ideas/widgets/funnel_kanban.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ideasState = ref.watch(ideasProvider);
final wsState = ref.watch(workspaceProvider);
final ideasNotifier = ref.read(ideasProvider.notifier);
return Scaffold(
appBar: AppBar(
title: Text(wsState.selectedWorkspace?.name ?? 'VoIdea'),
actions: [
IconButton(
icon: Icon(ideasState.viewMode == 'list'
? Icons.view_kanban
: Icons.view_list),
onPressed: () {
ideasNotifier.setViewMode(
ideasState.viewMode == 'list' ? 'kanban' : 'list');
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => context.push('/ideas/create'),
),
],
),
body: RefreshIndicator(
onRefresh: () => ideasNotifier.loadIdeas(),
child: _buildBody(context, ref, ideasState),
),
);
}
Widget _buildBody(BuildContext context, WidgetRef ref, IdeasState state) {
if (state.isLoading && state.ideas.isEmpty) {
return const ShimmerList();
}
if (state.error != null && state.ideas.isEmpty) {
return ErrorDisplay(
message: state.error!,
onRetry: () => ref.read(ideasProvider.notifier).loadIdeas(),
);
}
if (state.ideas.isEmpty) {
return EmptyState(
icon: Icons.lightbulb_outline,
title: 'Пока нет идей',
subtitle: 'Создайте первую идею',
actionLabel: 'Создать идею',
onAction: () => context.push('/ideas/create'),
);
}
if (state.viewMode == 'kanban') {
return IdeaFunnelKanban(ideas: state.ideas);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.ideas.length,
itemBuilder: (context, index) {
final idea = state.ideas[index];
return _IdeaCard(idea: idea).animate().fadeIn(
duration: 300.ms,
delay: (index * 50).ms,
);
},
);
}
}
class _IdeaCard extends StatelessWidget {
final Idea idea;
const _IdeaCard({required this.idea});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.push('/ideas/${idea.id}'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
idea.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (idea.funnelStatus != null)
FunnelStatusBadge(status: idea.funnelStatus),
],
),
const SizedBox(height: 8),
if (idea.description.isNotEmpty)
Text(
idea.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: VoIdeaColors.muted,
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.thumb_up_outlined,
size: 14, color: VoIdeaColors.muted),
const SizedBox(width: 4),
Text(idea.upvotes.toString(),
style: const TextStyle(
color: VoIdeaColors.muted, fontSize: 12)),
const SizedBox(width: 16),
Icon(Icons.thumb_down_outlined,
size: 14, color: VoIdeaColors.muted),
const SizedBox(width: 4),
Text(idea.downvotes.toString(),
style: const TextStyle(
color: VoIdeaColors.muted, fontSize: 12)),
const SizedBox(width: 16),
Icon(Icons.comment_outlined,
size: 14, color: VoIdeaColors.muted),
const SizedBox(width: 4),
Text(idea.commentCount.toString(),
style: const TextStyle(
color: VoIdeaColors.muted, fontSize: 12)),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/ideas_provider.dart';
import '../../../models/idea.dart';
import '../../../core/utils/validators.dart';
class IdeaCreateScreen extends ConsumerStatefulWidget {
const IdeaCreateScreen({super.key});
@override
ConsumerState<IdeaCreateScreen> createState() => _IdeaCreateScreenState();
}
class _IdeaCreateScreenState extends ConsumerState<IdeaCreateScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Новая идея'),
actions: [
TextButton(
onPressed: state.isLoading
? null
: () async {
if (_formKey.currentState!.validate()) {
final success = await notifier.createIdea(
CreateIdeaRequest(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
),
);
if (success && mounted) context.pop();
}
},
child: state.isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Сохранить'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Название',
hintText: 'О чём ваша идея?',
),
validator: (v) => Validators.required(v, 'Название'),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Описание',
hintText: 'Подробно опишите идею...',
alignLabelWithHint: true,
),
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/ideas_provider.dart';
import '../../../models/idea.dart';
class IdeaEditScreen extends ConsumerStatefulWidget {
final String id;
const IdeaEditScreen({super.key, required this.id});
@override
ConsumerState<IdeaEditScreen> createState() => _IdeaEditScreenState();
}
class _IdeaEditScreenState extends ConsumerState<IdeaEditScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
bool _initialized = false;
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(ideasProvider.notifier).loadIdea(widget.id);
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
if (!_initialized && state.selectedIdea != null) {
_titleController.text = state.selectedIdea!.title;
_descriptionController.text = state.selectedIdea!.description;
_initialized = true;
}
return Scaffold(
appBar: AppBar(
title: const Text('Редактировать идею'),
actions: [
TextButton(
onPressed: state.isLoading
? null
: () async {
if (_formKey.currentState!.validate()) {
final success = await notifier.updateIdea(
widget.id,
UpdateIdeaRequest(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
),
);
if (success && mounted) context.pop();
}
},
child: state.isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Сохранить'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Название'),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Описание',
alignLabelWithHint: true,
),
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/ideas_provider.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../widgets/empty_state.dart';
import '../../../widgets/funnel_status_badge.dart';
import '../../../core/theme/app_theme.dart';
import '../widgets/funnel_kanban.dart';
class IdeaListScreen extends ConsumerWidget {
const IdeaListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Идеи'),
actions: [
IconButton(
icon: Icon(state.viewMode == 'list'
? Icons.view_kanban
: Icons.view_list),
onPressed: () {
notifier.setViewMode(
state.viewMode == 'list' ? 'kanban' : 'list');
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => context.push('/ideas/create'),
),
],
),
body: RefreshIndicator(
onRefresh: () => notifier.loadIdeas(),
child: _buildBody(context, state, notifier),
),
);
}
Widget _buildBody(
BuildContext context, IdeasState state, IdeasNotifier notifier) {
if (state.isLoading && state.ideas.isEmpty) {
return const ShimmerList();
}
if (state.error != null && state.ideas.isEmpty) {
return ErrorDisplay(
message: state.error!,
onRetry: () => notifier.loadIdeas(),
);
}
if (state.ideas.isEmpty) {
return EmptyState(
icon: Icons.lightbulb_outline,
title: 'Идей пока нет',
actionLabel: 'Создать',
onAction: () => context.push('/ideas/create'),
);
}
if (state.viewMode == 'kanban') {
return IdeaFunnelKanban(ideas: state.ideas);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.ideas.length,
itemBuilder: (_, i) {
final idea = state.ideas[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(idea.title, maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: Text(idea.description, maxLines: 2,
overflow: TextOverflow.ellipsis),
trailing: idea.funnelStatus != null
? FunnelStatusBadge(status: idea.funnelStatus)
: null,
onTap: () => context.push('/ideas/${idea.id}'),
),
).animate().fadeIn(duration: 200.ms, delay: (i * 50).ms);
},
);
}
}
@@ -0,0 +1,411 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/ideas_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../models/idea.dart';
import '../../../models/collaboration.dart';
import '../../../widgets/vote_buttons.dart';
import '../../../widgets/comment_thread.dart';
import '../../../widgets/funnel_status_badge.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/client.dart';
import '../../../providers/disk_provider.dart';
class IdeaViewScreen extends ConsumerStatefulWidget {
final String id;
const IdeaViewScreen({super.key, required this.id});
@override
ConsumerState<IdeaViewScreen> createState() => _IdeaViewScreenState();
}
class _IdeaViewScreenState extends ConsumerState<IdeaViewScreen> {
final _commentController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(ideasProvider.notifier).loadIdea(widget.id);
ref.read(ideaCommentsProvider(widget.id).notifier).loadComments();
ref.read(ideaActivityProvider(widget.id).notifier).loadActivities();
});
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
void _showPresentationOptions(BuildContext context) {
final diskState = ref.read(diskProvider);
final hasDrive = diskState.connectedProviders.isNotEmpty;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Создать презентацию',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.download, color: VoIdeaColors.primary),
title: const Text('Скачать HTML'),
subtitle: const Text('Откроется в браузере'),
onTap: () {
Navigator.pop(ctx);
_generateAndDownload();
},
),
if (hasDrive)
...diskState.connectedProviders.map((p) {
final provider = p['provider'] as String? ?? '';
return ListTile(
leading: Icon(_providerIcon(provider),
color: VoIdeaColors.success),
title: Text('Отправить на ${_providerLabel(provider)}'),
subtitle: Text(p['email'] ?? ''),
onTap: () {
Navigator.pop(ctx);
_generateAndUpload(provider);
},
);
}),
if (!hasDrive)
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Подключите облачный диск в настройках, чтобы сохранять презентации в облако',
style: TextStyle(fontSize: 12, color: VoIdeaColors.muted),
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}
IconData _providerIcon(String provider) {
switch (provider) {
case 'google': return Icons.g_mobiledata;
case 'yandex': return Icons.cloud;
case 'apple': return Icons.apple;
default: return Icons.cloud_outlined;
}
}
String _providerLabel(String provider) {
switch (provider) {
case 'google': return 'Google Диск';
case 'yandex': return 'Яндекс Диск';
case 'apple': return 'iCloud';
default: return provider;
}
}
Future<void> _generateAndDownload() async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=download',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Презентация скачана')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e')),
);
}
}
}
Future<void> _generateAndUpload(String provider) async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=drive&drive_provider=$provider',
);
if (mounted) {
final data = resp.data as Map<String, dynamic>;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Презентация отправлена на ${_providerLabel(provider)}'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка загрузки: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final ideaState = ref.watch(ideasProvider);
final commentsState = ref.watch(ideaCommentsProvider(widget.id));
final activityState = ref.watch(ideaActivityProvider(widget.id));
final authState = ref.watch(authProvider);
final ideasNotifier = ref.read(ideasProvider.notifier);
final commentsNotifier =
ref.read(ideaCommentsProvider(widget.id).notifier);
final currentUserId = authState.user?.id ?? '';
final idea = ideaState.selectedIdea;
if (ideaState.isLoading && idea == null) {
return Scaffold(
appBar: AppBar(),
body: const LoadingIndicator(),
);
}
if (idea == null) {
return Scaffold(
appBar: AppBar(),
body: ErrorDisplay(
message: ideaState.error ?? 'Идея не найдена',
onRetry: () =>
ref.read(ideasProvider.notifier).loadIdea(widget.id),
),
);
}
final funnelIndex = AppConstants.funnelStates.indexOf(idea.funnelStatus ?? '');
final prevStatus = funnelIndex > 0
? AppConstants.funnelStates[funnelIndex - 1]
: null;
final nextStatus = funnelIndex < AppConstants.funnelStates.length - 1
? AppConstants.funnelStates[funnelIndex + 1]
: null;
return Scaffold(
appBar: AppBar(
title: const Text('Идея'),
actions: [
if (idea.userId == currentUserId)
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/ideas/${widget.id}/edit'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
idea.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (idea.funnelStatus != null)
FunnelStatusBadge(status: idea.funnelStatus),
],
).animate().fadeIn(),
const SizedBox(height: 8),
Row(
children: [
Text(
idea.userName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
const SizedBox(width: 16),
Text(
DateFormat('d MMM yyyy').format(idea.createdAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
],
).animate().fadeIn(delay: 100.ms),
const SizedBox(height: 16),
if (idea.description.isNotEmpty)
Text(
idea.description,
style: Theme.of(context).textTheme.bodyLarge,
).animate().fadeIn(delay: 200.ms),
const SizedBox(height: 24),
Row(
children: [
VoteButtons(
upvotes: idea.upvotes,
downvotes: idea.downvotes,
onUpvote: () =>
commentsNotifier.toggleVote(widget.id),
onDownvote: () =>
commentsNotifier.toggleVote(widget.id),
),
const Spacer(),
if (prevStatus != null)
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, prevStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[prevStatus]}',
),
if (nextStatus != null)
IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, nextStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[nextStatus]}',
),
],
).animate().fadeIn(delay: 300.ms),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.slideshow, size: 18),
label: const Text('Презентация'),
onPressed: () => _showPresentationOptions(context),
),
),
],
),
const Divider(height: 32),
Text(
'Комментарии',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Напишите комментарий...',
isDense: true,
),
textCapitalization: TextCapitalization.sentences,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
color: VoIdeaColors.primary,
onPressed: () {
if (_commentController.text.isNotEmpty) {
commentsNotifier
.addComment(_commentController.text.trim())
.then((_) =>
_commentController.clear());
}
},
),
],
),
const SizedBox(height: 16),
if (commentsState.isLoading)
const LoadingIndicator()
else
CommentThread(
comments: commentsState.comments,
currentUserId: currentUserId,
onAddComment: (body, {parentId}) =>
commentsNotifier.addComment(body, parentId: parentId),
).animate().fadeIn(delay: 500.ms),
const Divider(height: 32),
Text(
'Активность',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 600.ms),
const SizedBox(height: 12),
if (activityState.isLoading)
const LoadingIndicator()
else
...activityState.activities.map(
(a) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor:
VoIdeaColors.primary.withOpacity(0.1),
child: Text(
a.userName.isNotEmpty
? a.userName[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 10,
color: VoIdeaColors.primary),
),
),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: a.userName,
style: const TextStyle(
fontWeight: FontWeight.w500),
),
TextSpan(
text: ' ${a.action}',
),
if (a.description != null)
TextSpan(
text: ': ${a.description}',
style: TextStyle(
color: VoIdeaColors.muted),
),
],
),
style: const TextStyle(fontSize: 13),
),
),
Text(
DateFormat('HH:mm').format(a.createdAt),
style: const TextStyle(
fontSize: 11, color: VoIdeaColors.muted),
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../models/idea.dart';
import '../../../providers/ideas_provider.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../widgets/funnel_status_badge.dart';
class IdeaFunnelKanban extends ConsumerWidget {
final List<Idea> ideas;
const IdeaFunnelKanban({super.key, required this.ideas});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(ideasProvider.notifier);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: AppConstants.funnelStates.map((status) {
final columnIdeas =
ideas.where((i) => i.funnelStatus == status).toList();
final color = AppConstants.funnelColors[status]!;
final label = AppConstants.funnelLabels[status]!;
return Container(
width: 280,
margin: const EdgeInsets.all(8),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
fontWeight: FontWeight.w600, color: color)),
const Spacer(),
Text(
'${columnIdeas.length}',
style: TextStyle(color: color, fontSize: 12),
),
],
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: VoIdeaColors.surface,
border: Border.all(color: color.withOpacity(0.2)),
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(12)),
),
child: columnIdeas.isEmpty
? Center(
child: Text('Нет идей',
style: TextStyle(
color: VoIdeaColors.muted, fontSize: 12)),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: columnIdeas.length,
itemBuilder: (_, i) {
final idea = columnIdeas[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
context.push('/ideas/${idea.id}'),
onLongPress: () =>
_showKanbanContextMenu(context, ref, idea),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
idea.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_iconText(
Icons.thumb_up_outlined,
idea.upvotes.toString()),
const SizedBox(width: 8),
_iconText(
Icons.comment_outlined,
idea.commentCount
.toString()),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (i > 0 ||
AppConstants.funnelStates
.indexOf(status) >
0)
InkWell(
onTap: () => notifier
.updateFunnel(
idea.id,
AppConstants
.funnelStates[
AppConstants
.funnelStates
.indexOf(
status) -
1]),
child: const Icon(
Icons.chevron_left,
size: 20),
),
InkWell(
onTap: () => notifier
.updateFunnel(
idea.id,
AppConstants
.funnelStates[
AppConstants
.funnelStates
.indexOf(
status) +
1]),
child: const Icon(
Icons.chevron_right,
size: 20),
),
],
),
],
),
],
),
),
),
).animate().fadeIn(
duration: 200.ms,
delay: (i * 30).ms);
},
),
),
),
],
),
);
}).toList(),
),
);
}
Widget _iconText(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: VoIdeaColors.muted),
const SizedBox(width: 2),
Text(text,
style:
const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
],
);
}
void _showKanbanContextMenu(BuildContext context, WidgetRef ref, Idea idea) {
final notifier = ref.read(ideasProvider.notifier);
final funnelIdx = AppConstants.funnelStates.indexOf(idea.funnelStatus ?? '');
final canMoveLeft = funnelIdx > 0;
final canMoveRight =
funnelIdx < AppConstants.funnelStates.length - 1;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.slideshow, color: VoIdeaColors.primary),
title: const Text('Создать презентацию'),
onTap: () {
Navigator.pop(ctx);
context.push('/ideas/${idea.id}');
},
),
if (canMoveLeft)
ListTile(
leading: const Icon(Icons.arrow_back, color: VoIdeaColors.warning),
title: Text(
'Переместить в "${AppConstants.funnelLabels[AppConstants.funnelStates[funnelIdx - 1]]}"'),
onTap: () {
Navigator.pop(ctx);
notifier.updateFunnel(idea.id, AppConstants.funnelStates[funnelIdx - 1]);
},
),
if (canMoveRight)
ListTile(
leading: const Icon(Icons.arrow_forward, color: VoIdeaColors.success),
title: Text(
'Переместить в "${AppConstants.funnelLabels[AppConstants.funnelStates[funnelIdx + 1]]}"'),
onTap: () {
Navigator.pop(ctx);
notifier.updateFunnel(idea.id, AppConstants.funnelStates[funnelIdx + 1]);
},
),
],
),
),
),
);
}
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/api/client.dart';
import '../../core/api/endpoints.dart';
import '../../core/theme/app_theme.dart';
import '../../models/auth.dart';
import '../../providers/auth_provider.dart';
class CalendarIntegrationScreen extends ConsumerStatefulWidget {
const CalendarIntegrationScreen({super.key});
@override
ConsumerState<CalendarIntegrationScreen> createState() =>
_CalendarIntegrationScreenState();
}
class _CalendarIntegrationScreenState
extends ConsumerState<CalendarIntegrationScreen> {
List<dynamic> _providers = [];
Map<String, dynamic>? _status;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
try {
final api = ref.read(apiClientProvider);
final providersResp = await api.get(Endpoints.calendarProviders);
final statusResp = await api.get(Endpoints.calendarStatus);
if (mounted) {
setState(() {
_providers = providersResp.data as List<dynamic>? ?? [];
_status = statusResp.data as Map<String, dynamic>?;
});
}
} catch (_) {}
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authProvider).user;
final isConnected = user?.calendarProvider != null;
return Scaffold(
appBar: AppBar(title: const Text('Интеграция календаря')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isConnected) ...[
Card(
color: VoIdeaColors.success.withOpacity(0.05),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.check_circle,
color: VoIdeaColors.success),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Календарь подключён',
style: TextStyle(
fontWeight: FontWeight.w600)),
Text(
'Провайдер: ${user!.calendarProvider}',
style: const TextStyle(fontSize: 12),
),
],
),
),
TextButton(
onPressed: _disconnectCalendar,
child: const Text('Отключить',
style: TextStyle(
color: VoIdeaColors.error)),
),
],
),
),
),
] else
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(Icons.calendar_month,
size: 48, color: VoIdeaColors.primary),
const SizedBox(height: 16),
const Text(
'Подключите календарь для автоматического создания событий из идей',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
...(_providers.map((p) {
final name = p['name'] as String? ?? '';
final icon = name == 'google'
? Icons.g_mobiledata
: name == 'yandex'
? Icons.cloud
: Icons.calendar_today;
return Padding(
padding:
const EdgeInsets.only(bottom: 8),
child: OutlinedButton.icon(
onPressed: () =>
_connectProvider(name),
icon: Icon(icon),
label: Text(_providerLabel(name)),
),
);
})),
],
),
),
),
if (_status != null) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Статус календаря',
style: TextStyle(
fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
...(_status!.entries.map((e) => Padding(
padding:
const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Text('${e.key}: ',
style: const TextStyle(
fontSize: 13,
color:
VoIdeaColors.muted)),
Text('${e.value}',
style:
const TextStyle(fontSize: 13)),
],
),
))),
],
),
),
),
],
],
),
);
}
String _providerLabel(String name) {
switch (name) {
case 'google':
return 'Google Calendar';
case 'yandex':
return 'Яндекс Календарь';
case 'apple':
return 'Apple Календарь';
default:
return name;
}
}
Future<void> _connectProvider(String provider) async {
try {
final api = ref.read(apiClientProvider);
final response =
await api.get('${Endpoints.calendarProviders}/$provider');
final url = response.data['url'] as String?;
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
} catch (_) {}
}
Future<void> _disconnectCalendar() async {
try {
final api = ref.read(apiClientProvider);
await api.post(Endpoints.calendarDisconnect);
if (mounted) _loadData();
} catch (_) {}
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/disk_provider.dart';
import '../../core/theme/app_theme.dart';
class DiskIntegrationScreen extends ConsumerStatefulWidget {
const DiskIntegrationScreen({super.key});
@override
ConsumerState<DiskIntegrationScreen> createState() =>
_DiskIntegrationScreenState();
}
class _DiskIntegrationScreenState extends ConsumerState<DiskIntegrationScreen> {
@override
void initState() {
super.initState();
Future.microtask(() => ref.read(diskProvider.notifier).load());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(diskProvider);
final notifier = ref.read(diskProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Облачные диски')),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(Icons.cloud, size: 48,
color: VoIdeaColors.primary),
const SizedBox(height: 12),
const Text(
'Подключите облачный диск для сохранения презентаций и экспорта',
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 16),
...state.availableProviders.map((provider) {
final connected = state.isConnected(provider);
final info = connected
? state.connectedProviders.firstWhere(
(p) => p['provider'] == provider,
orElse: () => {},
)
: null;
final icon = _providerIcon(provider);
final label = _providerLabel(provider);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: connected
? VoIdeaColors.success.withOpacity(0.1)
: VoIdeaColors.muted.withOpacity(0.1),
child: Icon(icon,
color: connected
? VoIdeaColors.success
: VoIdeaColors.muted),
),
title: Text(label),
subtitle: connected
? Text(
info?['email'] ?? 'Подключён',
style: const TextStyle(fontSize: 12),
)
: const Text('Не подключён',
style: TextStyle(fontSize: 12)),
trailing: connected
? TextButton(
onPressed: () =>
_confirmDisconnect(context, notifier, provider),
child: const Text('Отключить',
style: TextStyle(color: VoIdeaColors.error)),
)
: ElevatedButton(
onPressed: () => _connect(context, notifier, provider),
child: const Text('Подключить'),
),
),
);
}),
],
),
);
}
IconData _providerIcon(String provider) {
switch (provider) {
case 'google':
return Icons.g_mobiledata;
case 'yandex':
return Icons.cloud;
case 'apple':
return Icons.apple;
default:
return Icons.drive_file_rename_outline;
}
}
String _providerLabel(String provider) {
switch (provider) {
case 'google':
return 'Google Диск';
case 'yandex':
return 'Яндекс Диск';
case 'apple':
return 'iCloud';
default:
return provider;
}
}
Future<void> _connect(
BuildContext context, DiskNotifier notifier, String provider) async {
final url = await notifier.getOAuthUrl(provider);
if (url != null && await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
// After OAuth callback, app returns with code in URL
// For simplicity, show a dialog to paste the code
if (mounted) _showCodeDialog(context, notifier, provider);
}
}
void _showCodeDialog(
BuildContext context, DiskNotifier notifier, String provider) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Подключить ${_providerLabel(provider)}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('После авторизации вставьте код из URL-параметра'),
const SizedBox(height: 12),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Код авторизации',
hintText: 'Вставьте code из URL',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () async {
if (controller.text.isNotEmpty) {
await notifier.connect(provider, controller.text.trim());
if (ctx.mounted) Navigator.pop(ctx);
}
},
child: const Text('Подключить'),
),
],
),
);
}
void _confirmDisconnect(
BuildContext context, DiskNotifier notifier, String provider) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('Отключить ${_providerLabel(provider)}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
notifier.disconnect(provider);
Navigator.pop(ctx);
},
child: const Text('Отключить'),
),
],
),
);
}
}
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../providers/notification_provider.dart';
import '../../widgets/loading_indicator.dart';
import '../../widgets/empty_state.dart';
import '../../core/theme/app_theme.dart';
class NotificationsScreen extends ConsumerStatefulWidget {
const NotificationsScreen({super.key});
@override
ConsumerState<NotificationsScreen> createState() =>
_NotificationsScreenState();
}
class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref.read(notificationProvider.notifier).loadNotifications());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(notificationProvider);
final notifier = ref.read(notificationProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Уведомления'),
actions: [
if (state.unreadCount > 0)
TextButton(
onPressed: notifier.markAllAsRead,
child: const Text('Прочитать все'),
),
],
),
body: state.isLoading
? const LoadingIndicator()
: state.notifications.isEmpty
? const EmptyState(
icon: Icons.notifications_none,
title: 'Нет уведомлений',
)
: RefreshIndicator(
onRefresh: notifier.loadNotifications,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.notifications.length,
itemBuilder: (_, i) {
final n = state.notifications[i];
return Dismissible(
key: Key(n.id),
direction: DismissDirection.endToStart,
onDismissed: (_) => notifier.markAsRead(n.id),
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: VoIdeaColors.primary,
child: const Icon(Icons.check, color: Colors.white),
),
child: Card(
margin: const EdgeInsets.only(bottom: 8),
color: n.isRead ? null : VoIdeaColors.primary.withOpacity(0.03),
child: ListTile(
leading: CircleAvatar(
radius: 18,
backgroundColor: n.isRead
? VoIdeaColors.muted.withOpacity(0.1)
: VoIdeaColors.primary.withOpacity(0.1),
child: Icon(
n.isRead
? Icons.notifications_none
: Icons.notifications_active,
size: 20,
color: n.isRead
? VoIdeaColors.muted
: VoIdeaColors.primary,
),
),
title: Text(
n.title,
style: TextStyle(
fontWeight: n.isRead
? FontWeight.normal
: FontWeight.w600,
fontSize: 14,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(n.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 4),
Text(
DateFormat('d MMM HH:mm')
.format(n.createdAt),
style: TextStyle(
fontSize: 11,
color: VoIdeaColors.muted,
),
),
],
),
onTap: () {
if (!n.isRead) notifier.markAsRead(n.id);
if (n.ideaId != null) {
context.push('/ideas/${n.ideaId}');
}
},
),
),
);
},
),
),
);
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../core/theme/app_theme.dart';
import '../../core/constants/app_constants.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _pageController = PageController();
int _currentPage = 0;
final _storage = const FlutterSecureStorage();
final _pages = const [
_OnboardingPageData(
icon: Icons.lightbulb_outline,
title: 'Добро пожаловать в VoIdea',
description: 'Голосовой помощник для управления идеями.\nЗаписывайте, развивайте и запускайте идеи голосом.',
),
_OnboardingPageData(
icon: Icons.mic,
title: 'Голосовой ввод',
description: 'Говорите — мы записываем.\nРаспознавание речи в реальном времени.',
),
_OnboardingPageData(
icon: Icons.view_kanban,
title: 'Воронка идей',
description: 'От сырой идеи до запуска.\nУправляйте статусами и трекайте прогресс.',
),
_OnboardingPageData(
icon: Icons.groups,
title: 'Командная работа',
description: 'Рабочие пространства, голосования, комментарии.\nВсё для совместной работы.',
),
];
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
Future<void> _complete() async {
await _storage.write(key: AppConstants.onboardingSeenKey, value: 'true');
if (mounted) context.go('/auth/login');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Align(
alignment: Alignment.topRight,
child: TextButton(
onPressed: _complete,
child: const Text('Пропустить'),
),
),
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: _pages.length,
onPageChanged: (i) => setState(() => _currentPage = i),
itemBuilder: (_, i) {
final page = _pages[i];
return Padding(
padding: const EdgeInsets.all(48),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(page.icon,
size: 80, color: VoIdeaColors.primary),
const SizedBox(height: 32),
Text(
page.title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
page.description,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(color: VoIdeaColors.muted),
),
],
),
).animate().fadeIn(duration: 400.ms);
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_pages.length,
(i) => AnimatedContainer(
duration: 200.ms,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == i ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == i
? VoIdeaColors.primary
: VoIdeaColors.muted.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _currentPage == _pages.length - 1
? _complete
: () => _pageController.nextPage(
duration: 300.ms,
curve: Curves.easeInOut,
),
child: Text(
_currentPage == _pages.length - 1
? 'Начать'
: 'Далее',
),
),
),
),
],
),
),
);
}
}
class _OnboardingPageData {
final IconData icon;
final String title;
final String description;
const _OnboardingPageData({
required this.icon,
required this.title,
required this.description,
});
}
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/theme_provider.dart';
import '../../../providers/workspace_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../integrations/calendar_integration_screen.dart';
import '../../integrations/disk_integration_screen.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final themeMode = ref.watch(themeModeProvider);
final themeNotifier = ref.read(themeModeProvider.notifier);
final user = authState.user;
return Scaffold(
appBar: AppBar(title: const Text('Настройки')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: VoIdeaColors.primary.withOpacity(0.1),
child: Text(
user?.name.isNotEmpty == true
? user!.name[0].toUpperCase()
: '?',
style: const TextStyle(
color: VoIdeaColors.primary,
fontWeight: FontWeight.w600,
),
),
),
title: Text(user?.name ?? 'Пользователь'),
subtitle: Text(user?.email ?? ''),
),
),
const SizedBox(height: 8),
const _SectionTitle('Внешний вид'),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.brightness_6),
title: const Text('Тема'),
trailing: DropdownButton<ThemeMode>(
value: themeMode,
underline: const SizedBox(),
items: const [
DropdownMenuItem(
value: ThemeMode.system, child: Text('Системная')),
DropdownMenuItem(
value: ThemeMode.light, child: Text('Светлая')),
DropdownMenuItem(
value: ThemeMode.dark, child: Text('Тёмная')),
],
onChanged: (mode) {
if (mode != null) themeNotifier.setTheme(mode);
},
),
),
],
),
),
const SizedBox(height: 16),
const _SectionTitle('Рабочие пространства'),
Card(
child: ListTile(
leading: const Icon(Icons.workspaces_outlined),
title: const Text('Управление пространствами'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/workspaces'),
),
),
const SizedBox(height: 16),
const _SectionTitle('Интеграции'),
Card(
child: ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Календарь'),
subtitle: Text(
user?.calendarProvider != null
? 'Подключён: ${user!.calendarProvider}'
: 'Не подключён',
style: const TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CalendarIntegrationScreen()),
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('Облачные диски'),
subtitle: const Text(
'Google Диск, Яндекс Диск, iCloud',
style: TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DiskIntegrationScreen()),
),
),
),
const SizedBox(height: 16),
const _SectionTitle('Аккаунт'),
Card(
child: ListTile(
leading: const Icon(Icons.logout, color: VoIdeaColors.error),
title: const Text('Выйти',
style: TextStyle(color: VoIdeaColors.error)),
onTap: () => _showLogoutDialog(context, ref),
),
),
const SizedBox(height: 32),
if (user?.isAdmin == true)
Card(
child: ListTile(
leading: const Icon(Icons.admin_panel_settings,
color: VoIdeaColors.secondary),
title: const Text('Админ-панель'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/admin'),
),
),
],
),
);
}
void _showLogoutDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Выйти'),
content: const Text('Вы уверены?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/auth/login');
},
child: const Text('Выйти'),
),
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: VoIdeaColors.muted,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'dart:async';
import '../../../core/theme/app_theme.dart';
import '../../../core/api/client.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/constants/app_constants.dart';
enum VoiceState { idle, listening, processing, error }
class VoiceInputScreen extends ConsumerStatefulWidget {
const VoiceInputScreen({super.key});
@override
ConsumerState<VoiceInputScreen> createState() => _VoiceInputScreenState();
}
class _VoiceInputScreenState extends ConsumerState<VoiceInputScreen>
with SingleTickerProviderStateMixin {
final SpeechToText _speech = SpeechToText();
VoiceState _voiceState = VoiceState.idle;
String _recognizedText = '';
String? _errorMessage;
bool _available = false;
late AnimationController _animController;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_initSpeech();
}
@override
void dispose() {
_animController.dispose();
_speech.stop();
super.dispose();
}
Future<void> _initSpeech() async {
_available = await _speech.initialize(
onError: (error) => setState(() {
_voiceState = VoiceState.error;
_errorMessage = error.errorMsg;
}),
onStatus: (status) {
if (status == 'done' || status == 'notListening') {
if (_voiceState == VoiceState.listening) {
_stopListening();
}
}
},
);
if (!_available && mounted) {
setState(() {
_errorMessage = 'Распознавание речи недоступно на этом устройстве';
});
}
}
Future<void> _startListening() async {
if (!_available) return;
setState(() {
_voiceState = VoiceState.listening;
_recognizedText = '';
_errorMessage = null;
});
await _speech.listen(
onResult: (result) {
setState(() => _recognizedText = result.recognizedWords);
},
listenFor: const Duration(seconds: AppConstants.maxVoiceDurationSeconds),
pauseFor: const Duration(seconds: 3),
partialResults: true,
localeId: 'ru_RU',
);
}
Future<void> _stopListening() async {
await _speech.stop();
if (_recognizedText.isNotEmpty) {
setState(() => _voiceState = VoiceState.processing);
await _sendToServer();
} else {
setState(() => _voiceState = VoiceState.idle);
}
}
Future<void> _sendToServer() async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post(
Endpoints.voiceTranscribe,
data: {'text': _recognizedText},
);
if (mounted) {
setState(() {
_voiceState = VoiceState.idle;
if (response.data['response'] != null) {
_recognizedText = response.data['response'] as String;
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_voiceState = VoiceState.error;
_errorMessage = 'Ошибка отправки';
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Голосовой ввод'),
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: () => context.push('/voice/sessions'),
),
],
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: _voiceState == VoiceState.listening
? _stopListening
: _voiceState == VoiceState.idle
? _startListening
: null,
child: _PulseAnimation(
listenable: _animController,
builder: (context, child) {
final scale = _voiceState == VoiceState.listening
? 1.0 + (_animController.value * 0.15)
: 1.0;
return Transform.scale(
scale: scale,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _voiceState == VoiceState.listening
? VoIdeaColors.error.withOpacity(0.1)
: VoIdeaColors.primary.withOpacity(0.1),
border: Border.all(
color: _voiceState == VoiceState.listening
? VoIdeaColors.error
: VoIdeaColors.primary,
width: 3,
),
),
child: Icon(
_voiceState == VoiceState.listening
? Icons.mic
: _voiceState == VoiceState.processing
? Icons.hourglass_top
: Icons.mic_none,
size: 48,
color: _voiceState == VoiceState.listening
? VoIdeaColors.error
: VoIdeaColors.primary,
),
),
);
},
),
),
const SizedBox(height: 32),
Text(
_voiceState == VoiceState.idle
? 'Нажмите для начала записи'
: _voiceState == VoiceState.listening
? 'Говорите...'
: _voiceState == VoiceState.processing
? 'Обработка...'
: 'Ошибка',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
if (_recognizedText.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: VoIdeaColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_recognizedText,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
_errorMessage!,
style: const TextStyle(color: VoIdeaColors.error),
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}
}
class _PulseAnimation extends AnimatedWidget {
final Widget Function(BuildContext context, Widget? child) builder;
const _PulseAnimation({
required super.listenable,
required this.builder,
});
@override
Widget build(BuildContext context) {
return builder(context, null);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/api/client.dart';
import '../../../core/api/endpoints.dart';
import '../../../models/voice.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/empty_state.dart';
import '../../../core/theme/app_theme.dart';
class VoiceSessionsScreen extends ConsumerStatefulWidget {
const VoiceSessionsScreen({super.key});
@override
ConsumerState<VoiceSessionsScreen> createState() =>
_VoiceSessionsScreenState();
}
class _VoiceSessionsScreenState extends ConsumerState<VoiceSessionsScreen> {
List<VoiceSession> _sessions = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSessions();
}
Future<void> _loadSessions() async {
setState(() => _isLoading = true);
try {
final api = ref.read(apiClientProvider);
final response = await api.get(Endpoints.voiceSessions);
final list = (response.data as List<dynamic>)
.map((e) => VoiceSession.fromJson(e as Map<String, dynamic>))
.toList();
if (mounted) setState(() => _sessions = list);
} catch (_) {}
if (mounted) setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('История сессий')),
body: _isLoading
? const LoadingIndicator()
: _sessions.isEmpty
? const EmptyState(
icon: Icons.history,
title: 'Нет сессий',
subtitle: 'Начните голосовой ввод',
)
: RefreshIndicator(
onRefresh: _loadSessions,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _sessions.length,
itemBuilder: (_, i) {
final session = _sessions[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor:
VoIdeaColors.primary.withOpacity(0.1),
child: const Icon(Icons.mic,
color: VoIdeaColors.primary),
),
title: Text(
session.title ?? 'Сессия ${i + 1}'),
subtitle: Text(
'${session.messageCount} сообщений · ${DateFormat('d MMM').format(session.createdAt)}',
style: const TextStyle(fontSize: 12),
),
trailing: Chip(
label: Text(
session.status,
style: const TextStyle(fontSize: 11),
),
),
),
);
},
),
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/workspace_provider.dart';
import '../../../providers/ideas_provider.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/empty_state.dart';
import '../../../models/workspace.dart';
import '../../../core/theme/app_theme.dart';
class WorkspaceDetailScreen extends ConsumerStatefulWidget {
final String id;
const WorkspaceDetailScreen({super.key, required this.id});
@override
ConsumerState<WorkspaceDetailScreen> createState() =>
_WorkspaceDetailScreenState();
}
class _WorkspaceDetailScreenState
extends ConsumerState<WorkspaceDetailScreen> {
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(workspaceProvider.notifier).loadMembers(widget.id);
});
}
@override
Widget build(BuildContext context) {
final wsState = ref.watch(workspaceProvider);
final wsNotifier = ref.read(workspaceProvider.notifier);
final workspace = wsState.workspaces.where((w) => w.id == widget.id).firstOrNull;
if (workspace == null) {
return Scaffold(
appBar: AppBar(),
body: const LoadingIndicator(),
);
}
return Scaffold(
appBar: AppBar(
title: Text(workspace.name),
actions: [
if (workspace.isTeam)
IconButton(
icon: const Icon(Icons.person_add),
onPressed: () => _showAddMemberDialog(context, wsNotifier),
),
],
),
body: Column(
children: [
if (workspace.description != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: VoIdeaColors.surface,
child: Text(workspace.description!),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.people, size: 20, color: VoIdeaColors.muted),
const SizedBox(width: 8),
Text(
'Участники (${wsState.members.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
Expanded(
child: wsState.members.isEmpty
? const EmptyState(
icon: Icons.people_outline,
title: 'Нет участников',
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: wsState.members.length,
itemBuilder: (_, i) {
final member = wsState.members[i];
return ListTile(
leading: CircleAvatar(
backgroundColor:
VoIdeaColors.primary.withOpacity(0.1),
child: Text(
member.userName.isNotEmpty
? member.userName[0].toUpperCase()
: '?',
style: const TextStyle(
color: VoIdeaColors.primary),
),
),
title: Text(member.userName),
subtitle: Text(member.userEmail,
style: const TextStyle(fontSize: 12)),
trailing: Chip(
label: Text(
member.role,
style: const TextStyle(fontSize: 11),
),
),
);
},
),
),
],
),
);
}
void _showAddMemberDialog(
BuildContext context, WorkspaceNotifier notifier) {
final emailController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Добавить участника'),
content: TextField(
controller: emailController,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'email@example.com',
),
keyboardType: TextInputType.emailAddress,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
if (emailController.text.isNotEmpty) {
notifier.addMember(widget.id, emailController.text.trim(), 'member');
Navigator.pop(ctx);
}
},
child: const Text('Добавить'),
),
],
),
);
}
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/workspace_provider.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/empty_state.dart';
import '../../../models/workspace.dart';
import '../../../core/theme/app_theme.dart';
class WorkspaceListScreen extends ConsumerStatefulWidget {
const WorkspaceListScreen({super.key});
@override
ConsumerState<WorkspaceListScreen> createState() =>
_WorkspaceListScreenState();
}
class _WorkspaceListScreenState extends ConsumerState<WorkspaceListScreen> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref.read(workspaceProvider.notifier).loadWorkspaces());
}
@override
Widget build(BuildContext context) {
final state = ref.watch(workspaceProvider);
final notifier = ref.read(workspaceProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Рабочие пространства'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showCreateDialog(context, notifier),
),
],
),
body: state.isLoading
? const LoadingIndicator()
: state.workspaces.isEmpty
? EmptyState(
icon: Icons.workspaces_outline,
title: 'Нет рабочих пространств',
actionLabel: 'Создать',
onAction: () =>
_showCreateDialog(context, notifier),
)
: RefreshIndicator(
onRefresh: notifier.loadWorkspaces,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.workspaces.length,
itemBuilder: (_, i) {
final ws = state.workspaces[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
backgroundColor: ws.isTeam
? VoIdeaColors.secondary.withOpacity(0.1)
: VoIdeaColors.primary.withOpacity(0.1),
child: Icon(
ws.isTeam
? Icons.groups
: Icons.person,
color: ws.isTeam
? VoIdeaColors.secondary
: VoIdeaColors.primary,
),
),
title: Text(ws.name),
subtitle: Text(
'${ws.isTeam ? 'Командное' : 'Личное'} · ${ws.memberCount} участников',
style: const TextStyle(fontSize: 12),
),
trailing: Chip(
label: Text(
ws.isPersonal ? 'Личное' : 'Команда',
style: const TextStyle(fontSize: 11),
),
),
onTap: () async {
await notifier.selectWorkspace(ws);
if (mounted) context.pop();
},
),
);
},
),
),
);
}
void _showCreateDialog(
BuildContext context, WorkspaceNotifier notifier) {
final nameController = TextEditingController();
final descController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Создать пространство'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Название',
),
),
const SizedBox(height: 12),
TextField(
controller: descController,
decoration: const InputDecoration(
labelText: 'Описание (необязательно)',
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () async {
if (nameController.text.isNotEmpty) {
await notifier.createWorkspace(CreateWorkspaceRequest(
name: nameController.text.trim(),
description: descController.text.trim(),
));
if (ctx.mounted) Navigator.pop(ctx);
}
},
child: const Text('Создать'),
),
],
),
);
}
}