feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -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('Создать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user