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
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/collaboration.dart';
import '../core/theme/app_theme.dart';
class CommentThread extends StatelessWidget {
final List<Comment> comments;
final String currentUserId;
final Future<bool> Function(String body, {String? parentId}) onAddComment;
const CommentThread({
super.key,
required this.comments,
required this.currentUserId,
required this.onAddComment,
});
@override
Widget build(BuildContext context) {
final topLevel = comments.where((c) => c.parentId == null).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...topLevel.map((comment) => _CommentCard(
comment: comment,
currentUserId: currentUserId,
onReply: (parentId, body) => onAddComment(body, parentId: parentId),
)),
],
);
}
}
class _CommentCard extends StatelessWidget {
final Comment comment;
final String currentUserId;
final Future<bool> Function(String parentId, String body) onReply;
const _CommentCard({
required this.comment,
required this.currentUserId,
required this.onReply,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: VoIdeaColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: VoIdeaColors.primary.withOpacity(0.1),
child: Text(
comment.userName.isNotEmpty
? comment.userName[0].toUpperCase()
: '?',
style: const TextStyle(
color: VoIdeaColors.primary,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Text(
comment.userName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Spacer(),
Text(
DateFormat('d MMM').format(comment.createdAt),
style: TextStyle(
color: VoIdeaColors.muted,
fontSize: 12,
),
),
],
),
const SizedBox(height: 8),
Text(comment.body),
if (comment.userId != currentUserId)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showReplyDialog(context),
child: Text(
'Ответить',
style: TextStyle(
color: VoIdeaColors.primary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
if (comment.replies.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 24, top: 8),
child: Column(
children: comment.replies.map((reply) => _CommentCard(
comment: reply,
currentUserId: currentUserId,
onReply: onReply,
)).toList(),
),
),
],
),
);
}
void _showReplyDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ответить'),
content: TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Напишите ответ...',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
onReply(comment.id, controller.text);
Navigator.pop(ctx);
}
},
child: const Text('Отправить'),
),
],
),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../core/theme/app_theme.dart';
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final String? actionLabel;
final VoidCallback? onAction;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: VoIdeaColors.muted),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: VoIdeaColors.muted,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: VoIdeaColors.muted,
),
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.2);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../core/theme/app_theme.dart';
class ErrorDisplay extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const ErrorDisplay({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
size: 48, color: VoIdeaColors.error),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
if (onRetry != null) ...[
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Повторить'),
),
],
],
),
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../core/constants/app_constants.dart';
class FunnelStatusBadge extends StatelessWidget {
final String? status;
final double fontSize;
const FunnelStatusBadge({
super.key,
required this.status,
this.fontSize = 12,
});
@override
Widget build(BuildContext context) {
final color = status != null
? AppConstants.funnelColors[status] ?? AppConstants.funnelColors['raw']!
: AppConstants.funnelColors['raw']!;
final label = status != null
? AppConstants.funnelLabels[status] ?? status
: 'Без статуса';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
label!,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: FontWeight.w500,
),
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../core/theme/app_theme.dart';
class LoadingIndicator extends StatelessWidget {
final String? message;
const LoadingIndicator({super.key, this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (message != null) ...[
const SizedBox(height: 16),
Text(message!, style: Theme.of(context).textTheme.bodyMedium),
],
],
),
).animate().fadeIn(duration: 200.ms);
}
}
class ShimmerList extends StatelessWidget {
final int itemCount;
const ShimmerList({super.key, this.itemCount = 5});
@override
Widget build(BuildContext context) {
return Shimmer.fromColors(
baseColor: VoIdeaColors.muted.withOpacity(0.1),
highlightColor: VoIdeaColors.muted.withOpacity(0.05),
child: ListView.builder(
itemCount: itemCount,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/notification_provider.dart';
import '../models/collaboration.dart';
import '../core/theme/app_theme.dart';
class NotificationBell extends ConsumerWidget {
const NotificationBell({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadCountProvider);
return Stack(
children: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => context.push('/notifications'),
),
if (unreadCount > 0)
Positioned(
right: 6,
top: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: VoIdeaColors.error,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
unreadCount > 99 ? '99+' : unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
}
class NotificationDropdown extends ConsumerStatefulWidget {
const NotificationDropdown({super.key});
@override
ConsumerState<NotificationDropdown> createState() =>
_NotificationDropdownState();
}
class _NotificationDropdownState
extends ConsumerState<NotificationDropdown> {
@override
Widget build(BuildContext context) {
final state = ref.watch(notificationProvider);
final notifier = ref.read(notificationProvider.notifier);
return PopupMenuButton(
icon: const NotificationBell(),
onOpened: () => notifier.loadNotifications(),
itemBuilder: (context) {
final items = state.notifications.take(5).toList();
if (items.isEmpty) {
return [
const PopupMenuItem(
enabled: false,
child: Text('Нет уведомлений'),
),
];
}
return [
...items.map((n) => PopupMenuItem(
child: ListTile(
title: Text(
n.title,
style: TextStyle(
fontWeight: n.isRead ? FontWeight.normal : FontWeight.w600,
fontSize: 13,
),
),
subtitle: Text(n.body, maxLines: 1, overflow: TextOverflow.ellipsis),
dense: true,
onTap: () {
notifier.markAsRead(n.id);
if (n.ideaId != null) {
context.push('/ideas/${n.ideaId}');
}
},
),
)),
const PopupMenuDivider(),
PopupMenuItem(
child: TextButton(
onPressed: () => context.push('/notifications'),
child: const Text('Все уведомления'),
),
),
];
},
);
}
}
+82
View File
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../providers/notification_provider.dart';
import '../core/constants/app_constants.dart';
class Shell extends ConsumerWidget {
final Widget child;
const Shell({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadCountProvider);
final colorScheme = Theme.of(context).colorScheme;
final currentLocation = GoRouterState.of(context).matchedLocation;
int currentIndex = _navIndex(currentLocation);
return Scaffold(
body: child,
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: (index) => _onTap(context, index),
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.dashboard_outlined),
activeIcon: Icon(Icons.dashboard),
label: 'Дашборд',
),
const BottomNavigationBarItem(
icon: Icon(Icons.lightbulb_outline),
activeIcon: Icon(Icons.lightbulb),
label: 'Идеи',
),
const BottomNavigationBarItem(
icon: Icon(Icons.mic_outlined),
activeIcon: Icon(Icons.mic),
label: 'Голос',
),
const BottomNavigationBarItem(
icon: Icon(Icons.notifications_outlined),
activeIcon: Icon(Icons.notifications),
label: 'Уведомления',
),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Настройки',
),
],
),
).animate().fadeIn(duration: 200.ms);
}
int _navIndex(String location) {
if (location.startsWith('/ideas')) return 1;
if (location.startsWith('/voice')) return 2;
if (location.startsWith('/notifications')) return 3;
if (location.startsWith('/settings') ||
location.startsWith('/admin') ||
location.startsWith('/workspaces')) return 4;
return 0;
}
void _onTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/');
case 1:
context.go('/ideas');
case 2:
context.go('/voice');
case 3:
context.go('/notifications');
case 4:
context.go('/settings');
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../core/theme/app_theme.dart';
class VoteButtons extends StatefulWidget {
final int upvotes;
final int downvotes;
final bool? userVote;
final VoidCallback onUpvote;
final VoidCallback onDownvote;
const VoteButtons({
super.key,
required this.upvotes,
required this.downvotes,
this.userVote,
required this.onUpvote,
required this.onDownvote,
});
@override
State<VoteButtons> createState() => _VoteButtonsState();
}
class _VoteButtonsState extends State<VoteButtons> {
bool _animatingUp = false;
bool _animatingDown = false;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildButton(
icon: Icons.thumb_up_outlined,
activeIcon: Icons.thumb_up,
count: widget.upvotes,
isActive: widget.userVote == true,
color: VoIdeaColors.success,
onTap: () {
setState(() => _animatingUp = true);
widget.onUpvote();
Future.delayed(300.ms, () {
if (mounted) setState(() => _animatingUp = false);
});
},
animating: _animatingUp,
),
const SizedBox(width: 16),
_buildButton(
icon: Icons.thumb_down_outlined,
activeIcon: Icons.thumb_down,
count: widget.downvotes,
isActive: widget.userVote == false,
color: VoIdeaColors.error,
onTap: () {
setState(() => _animatingDown = true);
widget.onDownvote();
Future.delayed(300.ms, () {
if (mounted) setState(() => _animatingDown = false);
});
},
animating: _animatingDown,
),
],
);
}
Widget _buildButton({
required IconData icon,
required IconData activeIcon,
required int count,
required bool isActive,
required Color color,
required VoidCallback onTap,
required bool animating,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: 200.ms,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isActive ? color.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive ? color : VoIdeaColors.muted.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isActive ? activeIcon : icon,
size: 18,
color: isActive ? color : VoIdeaColors.muted,
).animate(target: animating ? 1 : 0).scale(
begin: const Offset(1, 1),
end: const Offset(1.3, 1.3),
duration: 200.ms,
),
const SizedBox(width: 4),
Text(
count.toString(),
style: TextStyle(
color: isActive ? color : VoIdeaColors.muted,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
}