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

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
@@ -0,0 +1,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),
),
],
),
),
),
],
),
),
);
}
}