feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -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('Отправить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('Все уведомления'),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user