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
+51
View File
@@ -0,0 +1,51 @@
import '../core/api/client.dart';
import '../core/api/endpoints.dart';
class DiskApi {
final ApiClient _api;
DiskApi(this._api);
Future<List<String>> getProviders() async {
final resp = await _api.get(Endpoints.diskProviders);
final list = resp.data['providers'] as List<dynamic>? ?? [];
return list.map((e) => e as String).toList();
}
Future<Map<String, dynamic>> getStatus() async {
final resp = await _api.get(Endpoints.diskStatus);
return resp.data as Map<String, dynamic>? ?? {};
}
Future<bool> connect(String provider, String code) async {
try {
await _api.post(Endpoints.diskConnect, data: {
'provider': provider,
'code': code,
});
return true;
} catch (_) {
return false;
}
}
Future<bool> disconnect(String provider) async {
try {
await _api.post(Endpoints.diskDisconnect, data: {
'provider': provider,
});
return true;
} catch (_) {
return false;
}
}
Future<String?> getOAuthUrl(String provider) async {
try {
final resp = await _api.get(Endpoints.diskOAuthUrl(provider));
return resp.data['url'] as String?;
} catch (_) {
return null;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';
import 'providers/theme_provider.dart';
class VoIdeaApp extends ConsumerWidget {
const VoIdeaApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'VoIdea',
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: themeMode,
routerConfig: router,
);
}
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_constants.dart';
import 'endpoints.dart';
import 'exceptions.dart';
class ApiClient {
late final Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
static const _tokenKey = 'access_token';
static const _refreshKey = 'refresh_token';
ApiClient() {
_dio = Dio(BaseOptions(
baseUrl: AppConstants.apiBaseUrl,
connectTimeout: AppConstants.connectTimeout,
receiveTimeout: AppConstants.receiveTimeout,
headers: {'Content-Type': 'application/json'},
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
final refreshed = await _tryRefresh();
if (refreshed) {
final retryResponse = await _retry(error.requestOptions);
handler.resolve(retryResponse);
return;
}
}
handler.next(error);
},
));
}
Future<bool> _tryRefresh() async {
try {
final refreshToken = await _storage.read(key: _refreshKey);
if (refreshToken == null) return false;
final response = await Dio(BaseOptions(
baseUrl: AppConstants.apiBaseUrl,
)).post(Endpoints.refresh, data: {'refresh_token': refreshToken});
if (response.statusCode == 200) {
await _storage.write(
key: _tokenKey, value: response.data['access_token']);
await _storage.write(
key: _refreshKey, value: response.data['refresh_token']);
return true;
}
} catch (_) {}
return false;
}
Future<Response> _retry(RequestOptions requestOptions) async {
final token = await _storage.read(key: _tokenKey);
final options = Options(
method: requestOptions.method,
headers: {
...requestOptions.headers,
'Authorization': 'Bearer $token',
},
);
return _dio.request(requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options);
}
Future<Response<T>> _request<T>(
String method,
String path, {
Map<String, dynamic>? data,
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await _dio.request<T>(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
return response;
} on DioException catch (e) {
throw _mapError(e);
}
}
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_request('GET', path, queryParameters: params);
Future<Response> post(String path, {Map<String, dynamic>? data}) =>
_request('POST', path, data: data);
Future<Response> put(String path, {Map<String, dynamic>? data}) =>
_request('PUT', path, data: data);
Future<Response> patch(String path, {Map<String, dynamic>? data}) =>
_request('PATCH', path, data: data);
Future<Response> delete(String path) => _request('DELETE', path);
Future<void> saveTokens(
String accessToken, String refreshToken) async {
await _storage.write(key: _tokenKey, value: accessToken);
await _storage.write(key: _refreshKey, value: refreshToken);
}
Future<void> clearTokens() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _refreshKey);
}
Future<String?> getAccessToken() => _storage.read(key: _tokenKey);
ApiException _mapError(DioException e) {
final statusCode = e.response?.statusCode;
final data = e.response?.data;
final message = data is Map ? data['detail']?.toString() ?? '' : '';
switch (statusCode) {
case 401:
return UnauthorizedException(message);
case 404:
return NotFoundException(message);
case 422:
return ValidationException(
message, data is Map ? data['errors'] : null);
case 403:
return TariffLimitException(message);
case 500:
return ServerException(message);
default:
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError) {
return NetworkException();
}
return ApiException(message, statusCode: statusCode);
}
}
}
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
+63
View File
@@ -0,0 +1,63 @@
class Endpoints {
Endpoints._();
static const String auth = '/auth';
static const String login = '/auth/login';
static const String register = '/auth/register';
static const String refresh = '/auth/refresh';
static const String twoFactor = '/auth/2fa';
static const String forgotPassword = '/auth/forgot-password';
static const String resetPassword = '/auth/reset-password';
static const String oauth = '/auth/oauth';
static const String ideas = '/ideas';
static String idea(String id) => '/ideas/$id';
static String ideaFunnel(String id) => '/ideas/$id/funnel';
static String ideaComments(String id) => '/ideas/$id/comments';
static String ideaVote(String id) => '/ideas/$id/vote';
static String ideaActivity(String id) => '/ideas/$id/activity';
static String ideaCalendar(String id) => '/ideas/$id/calendar';
static const String voiceTranscribe = '/voice/transcribe';
static const String voiceChat = '/voice/chat';
static const String voiceSessions = '/voice/sessions';
static String voiceSession(String id) => '/voice/sessions/$id';
static String voiceCommands(String sessionId) =>
'/voice/sessions/$sessionId/commands';
static const String workspaces = '/workspaces';
static String workspace(String id) => '/workspaces/$id';
static String workspaceMembers(String id) => '/workspaces/$id/members';
static String workspaceMember(String wid, String uid) =>
'/workspaces/$wid/members/$uid';
static const String notifications = '/notifications';
static String notification(String id) => '/notifications/$id';
static const String notificationsReadAll = '/notifications/read-all';
static const String notificationsUnreadCount = '/notifications/unread-count';
static const String calendarProviders = '/calendar/providers';
static const String calendarStatus = '/calendar/status';
static const String calendarDisconnect = '/calendar/disconnect';
static const String diskProviders = '/disk/providers';
static const String diskStatus = '/disk/status';
static const String diskConnect = '/disk/connect';
static const String diskDisconnect = '/disk/disconnect';
static String diskOAuthUrl(String provider) => '/disk/oauth-url/$provider';
static String ideaPresentation(String id) => '/ideas/$id/presentation';
static const String adminUsers = '/admin/users';
static String adminUser(String id) => '/admin/users/$id';
static const adminAgents = '/admin/agents';
static String adminAgent(String name) => '/admin/agents/$name';
static String adminAgentRun(String name) => '/admin/agents/$name/run';
static const String adminAgentsRunAll = '/admin/agents/run-all';
static const String adminLogs = '/admin/logs';
static const String adminTariffs = '/admin/tariffs';
static String adminTariff(String id) => '/admin/tariffs/$id';
static const String adminBotCommands = '/admin/bot-commands';
static const String adminStats = '/admin/stats';
static const String adminHealth = '/admin/health';
}
+41
View File
@@ -0,0 +1,41 @@
class ApiException implements Exception {
final String message;
final int? statusCode;
final Map<String, dynamic>? errors;
ApiException(this.message, {this.statusCode, this.errors});
@override
String toString() => 'ApiException($statusCode): $message';
}
class UnauthorizedException extends ApiException {
UnauthorizedException([String? message])
: super(message ?? 'Требуется авторизация', statusCode: 401);
}
class NotFoundException extends ApiException {
NotFoundException([String? message])
: super(message ?? 'Ресурс не найден', statusCode: 404);
}
class ValidationException extends ApiException {
ValidationException([String? message, Map<String, dynamic>? errors])
: super(message ?? 'Ошибка валидации',
statusCode: 422, errors: errors);
}
class ServerException extends ApiException {
ServerException([String? message])
: super(message ?? 'Внутренняя ошибка сервера', statusCode: 500);
}
class NetworkException extends ApiException {
NetworkException([String? message])
: super(message ?? 'Ошибка сети', statusCode: null);
}
class TariffLimitException extends ApiException {
TariffLimitException([String? message])
: super(message ?? 'Достигнут лимит тарифа', statusCode: 403);
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class AppConstants {
AppConstants._();
static const String appName = 'VoIdea';
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://voidea.app/api/v1',
);
static const Duration connectTimeout = Duration(seconds: 15);
static const Duration receiveTimeout = Duration(seconds: 15);
static const int maxVoiceDurationSeconds = 300;
static const int maxVoiceMessagesPerMinute = 10;
static const int tokenRefreshBufferMinutes = 5;
static const String supportEmail = 'support@voidea.app';
static const String onboardingSeenKey = 'onboarding_seen';
static const String demoEmail = 'demo@voidea.app';
static const String demoPassword = 'demo1234';
static const List<String> funnelStates = [
'raw', 'validated', 'backlog', 'in_progress', 'launched', 'retrospective',
];
static const Map<String, Color> funnelColors = {
'raw': Color(0xFF9CA3AF),
'validated': Color(0xFF60A5FA),
'backlog': Color(0xFFA78BFA),
'in_progress': Color(0xFFF59E0B),
'launched': Color(0xFF34D399),
'retrospective': Color(0xFF6B7280),
};
static const Map<String, String> funnelLabels = {
'raw': 'Сырая',
'validated': 'Подтверждена',
'backlog': 'Бэклог',
'in_progress': 'В работе',
'launched': 'Запущена',
'retrospective': 'Ретроспектива',
};
}
+129
View File
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../features/auth/screens/login_screen.dart';
import '../../features/auth/screens/register_screen.dart';
import '../../features/auth/screens/two_factor_screen.dart';
import '../../features/auth/screens/forgot_password_screen.dart';
import '../../features/auth/screens/reset_password_screen.dart';
import '../../features/dashboard/dashboard_screen.dart';
import '../../features/ideas/screens/idea_list_screen.dart';
import '../../features/ideas/screens/idea_view_screen.dart';
import '../../features/ideas/screens/idea_create_screen.dart';
import '../../features/ideas/screens/idea_edit_screen.dart';
import '../../features/voice/screens/voice_input_screen.dart';
import '../../features/voice/screens/voice_sessions_screen.dart';
import '../../features/workspaces/screens/workspace_list_screen.dart';
import '../../features/workspaces/screens/workspace_detail_screen.dart';
import '../../features/notifications/notifications_screen.dart';
import '../../features/settings/screens/settings_screen.dart';
import '../../features/admin/screens/admin_screen.dart';
import '../../features/onboarding/onboarding_screen.dart';
import '../../widgets/shell.dart';
import '../api/client.dart';
import '../constants/app_constants.dart';
final _storage = FlutterSecureStorage();
final routerProvider = Provider<GoRouter>((ref) {
final apiClient = ref.read(apiClientProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) async {
final token = await apiClient.getAccessToken();
final onboardingSeen =
await _storage.read(key: AppConstants.onboardingSeenKey);
final isAuthRoute = state.matchedLocation.startsWith('/auth') ||
state.matchedLocation == '/onboarding';
if (token == null && !isAuthRoute) {
if (onboardingSeen == null) return '/onboarding';
return '/auth/login';
}
if (token != null && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/onboarding',
builder: (_, __) => const OnboardingScreen(),
),
GoRoute(
path: '/auth/login',
builder: (_, __) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (_, __) => const RegisterScreen(),
),
GoRoute(
path: '/auth/2fa',
builder: (_, __) => const TwoFactorScreen(),
),
GoRoute(
path: '/auth/forgot-password',
builder: (_, __) => const ForgotPasswordScreen(),
),
GoRoute(
path: '/auth/reset-password',
builder: (_, __) => const ResetPasswordScreen(),
),
ShellRoute(
builder: (_, __, child) => Shell(child: child),
routes: [
GoRoute(
path: '/',
builder: (_, __) => const DashboardScreen(),
),
GoRoute(
path: '/ideas',
builder: (_, __) => const IdeaListScreen(),
),
GoRoute(
path: '/ideas/create',
builder: (_, __) => const IdeaCreateScreen(),
),
GoRoute(
path: '/ideas/:id',
builder: (_, state) =>
IdeaViewScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/ideas/:id/edit',
builder: (_, state) =>
IdeaEditScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/voice',
builder: (_, __) => const VoiceInputScreen(),
),
GoRoute(
path: '/voice/sessions',
builder: (_, __) => const VoiceSessionsScreen(),
),
GoRoute(
path: '/workspaces',
builder: (_, __) => const WorkspaceListScreen(),
),
GoRoute(
path: '/workspaces/:id',
builder: (_, state) =>
WorkspaceDetailScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: '/notifications',
builder: (_, __) => const NotificationsScreen(),
),
GoRoute(
path: '/settings',
builder: (_, __) => const SettingsScreen(),
),
GoRoute(
path: '/admin',
builder: (_, __) => const AdminScreen(),
),
],
),
],
);
});
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
class VoIdeaColors {
VoIdeaColors._();
static const Color primary = Color(0xFF2563EB);
static const Color primaryLight = Color(0xFF60A5FA);
static const Color primaryDark = Color(0xFF1D4ED8);
static const Color secondary = Color(0xFF7C3AED);
static const Color surface = Color(0xFFF8FAFC);
static const Color surfaceDark = Color(0xFF0F172A);
static const Color background = Color(0xFFFFFFFF);
static const Color backgroundDark = Color(0xFF020617);
static const Color error = Color(0xFFEF4444);
static const Color success = Color(0xFF22C55E);
static const Color warning = Color(0xFFF59E0B);
static const Color onPrimary = Color(0xFFFFFFFF);
static const Color onSurface = Color(0xFF1E293B);
static const Color onSurfaceDark = Color(0xFFE2E8F0);
static const Color muted = Color(0xFF94A3B8);
}
class AppTheme {
AppTheme._();
static ThemeData light() {
final colorScheme = ColorScheme.light(
primary: VoIdeaColors.primary,
primaryContainer: VoIdeaColors.primaryLight,
onPrimary: VoIdeaColors.onPrimary,
secondary: VoIdeaColors.secondary,
surface: VoIdeaColors.surface,
onSurface: VoIdeaColors.onSurface,
error: VoIdeaColors.error,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: VoIdeaColors.background,
appBarTheme: AppBarTheme(
backgroundColor: VoIdeaColors.background,
foregroundColor: VoIdeaColors.onSurface,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: VoIdeaColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: VoIdeaColors.background,
selectedItemColor: VoIdeaColors.primary,
unselectedItemColor: VoIdeaColors.muted,
type: BottomNavigationBarType.fixed,
elevation: 8,
),
cardTheme: CardThemeData(
color: VoIdeaColors.background,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: VoIdeaColors.primary,
foregroundColor: VoIdeaColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: VoIdeaColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: VoIdeaColors.muted.withOpacity(0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: VoIdeaColors.primary, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
dividerTheme: DividerThemeData(
color: VoIdeaColors.muted.withOpacity(0.2),
thickness: 1,
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
}
static ThemeData dark() {
final colorScheme = ColorScheme.dark(
primary: VoIdeaColors.primaryLight,
primaryContainer: VoIdeaColors.primaryDark,
onPrimary: VoIdeaColors.onPrimary,
secondary: VoIdeaColors.secondary,
surface: VoIdeaColors.surfaceDark,
onSurface: VoIdeaColors.onSurfaceDark,
error: VoIdeaColors.error,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: VoIdeaColors.backgroundDark,
appBarTheme: AppBarTheme(
backgroundColor: VoIdeaColors.backgroundDark,
foregroundColor: VoIdeaColors.onSurfaceDark,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
color: VoIdeaColors.onSurfaceDark,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: VoIdeaColors.backgroundDark,
selectedItemColor: VoIdeaColors.primaryLight,
unselectedItemColor: VoIdeaColors.muted,
type: BottomNavigationBarType.fixed,
elevation: 8,
),
cardTheme: CardThemeData(
color: VoIdeaColors.surfaceDark,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: VoIdeaColors.primaryLight,
foregroundColor: VoIdeaColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: VoIdeaColors.surfaceDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
BorderSide(color: VoIdeaColors.muted.withOpacity(0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide:
const BorderSide(color: VoIdeaColors.primaryLight, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
dividerTheme: DividerThemeData(
color: VoIdeaColors.muted.withOpacity(0.2),
thickness: 1,
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
class Validators {
Validators._();
static String? email(String? value) {
if (value == null || value.isEmpty) return 'Введите email';
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!regex.hasMatch(value)) return 'Некорректный email';
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) return 'Введите пароль';
if (value.length < 8) return 'Минимум 8 символов';
return null;
}
static String? required(String? value, [String field = 'Поле']) {
if (value == null || value.trim().isEmpty) return '$field обязательно';
return null;
}
static String? confirmPassword(String? value, String password) {
if (value != password) return 'Пароли не совпадают';
return null;
}
}
@@ -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('Создать'),
),
],
),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
runApp(const ProviderScope(child: VoIdeaApp()));
}
+134
View File
@@ -0,0 +1,134 @@
class AgentConfig {
final String name;
final String description;
final bool isActive;
final String schedule;
final Map<String, dynamic> config;
AgentConfig({
required this.name,
this.description = '',
this.isActive = true,
this.schedule = '',
this.config = const {},
});
factory AgentConfig.fromJson(Map<String, dynamic> json) => AgentConfig(
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
isActive: json['is_active'] as bool? ?? true,
schedule: json['schedule'] as String? ?? '',
config: json['config'] as Map<String, dynamic>? ?? {},
);
}
class AgentRunResult {
final String agent;
final String status;
final String? message;
AgentRunResult({
required this.agent,
required this.status,
this.message,
});
factory AgentRunResult.fromJson(Map<String, dynamic> json) => AgentRunResult(
agent: json['agent'] as String? ?? '',
status: json['status'] as String? ?? 'error',
message: json['message'] as String?,
);
}
class BotCommand {
final String id;
final String command;
final String description;
final bool isEnabled;
BotCommand({
required this.id,
required this.command,
this.description = '',
this.isEnabled = true,
});
factory BotCommand.fromJson(Map<String, dynamic> json) => BotCommand(
id: json['id'] as String,
command: json['command'] as String,
description: json['description'] as String? ?? '',
isEnabled: json['is_enabled'] as bool? ?? true,
);
}
class LogEntry {
final String id;
final String level;
final String message;
final String? source;
final DateTime createdAt;
LogEntry({
required this.id,
required this.level,
required this.message,
this.source,
required this.createdAt,
});
factory LogEntry.fromJson(Map<String, dynamic> json) => LogEntry(
id: json['id'] as String,
level: json['level'] as String? ?? 'INFO',
message: json['message'] as String,
source: json['source'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
class AdminStats {
final int totalUsers;
final int totalIdeas;
final int totalVoiceSessions;
final int totalWorkspaces;
final int activeAgents;
final int totalNotifications;
AdminStats({
this.totalUsers = 0,
this.totalIdeas = 0,
this.totalVoiceSessions = 0,
this.totalWorkspaces = 0,
this.activeAgents = 0,
this.totalNotifications = 0,
});
factory AdminStats.fromJson(Map<String, dynamic> json) => AdminStats(
totalUsers: json['total_users'] as int? ?? 0,
totalIdeas: json['total_ideas'] as int? ?? 0,
totalVoiceSessions: json['total_voice_sessions'] as int? ?? 0,
totalWorkspaces: json['total_workspaces'] as int? ?? 0,
activeAgents: json['active_agents'] as int? ?? 0,
totalNotifications: json['total_notifications'] as int? ?? 0,
);
}
class HealthStatus {
final String status;
final String version;
final double uptimeSeconds;
final Map<String, dynamic> services;
HealthStatus({
this.status = 'ok',
this.version = '',
this.uptimeSeconds = 0,
this.services = const {},
});
factory HealthStatus.fromJson(Map<String, dynamic> json) => HealthStatus(
status: json['status'] as String? ?? 'ok',
version: json['version'] as String? ?? '',
uptimeSeconds: (json['uptime_seconds'] as num?)?.toDouble() ?? 0,
services: json['services'] as Map<String, dynamic>? ?? {},
);
}
+119
View File
@@ -0,0 +1,119 @@
class LoginRequest {
final String email;
final String password;
LoginRequest({required this.email, required this.password});
Map<String, dynamic> toJson() => {
'email': email,
'password': password,
};
}
class RegisterRequest {
final String email;
final String password;
final String name;
RegisterRequest({
required this.email,
required this.password,
required this.name,
});
Map<String, dynamic> toJson() => {
'email': email,
'password': password,
'name': name,
};
}
class AuthResponse {
final String accessToken;
final String refreshToken;
final User user;
AuthResponse({
required this.accessToken,
required this.refreshToken,
required this.user,
});
factory AuthResponse.fromJson(Map<String, dynamic> json) => AuthResponse(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
user: User.fromJson(json['user'] as Map<String, dynamic>),
);
}
class User {
final String id;
final String email;
final String name;
final bool isAdmin;
final bool isTwoFactorEnabled;
final String? tariffId;
final String? calendarProvider;
final DateTime createdAt;
User({
required this.id,
required this.email,
required this.name,
this.isAdmin = false,
this.isTwoFactorEnabled = false,
this.tariffId,
this.calendarProvider,
required this.createdAt,
});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
email: json['email'] as String,
name: json['name'] as String? ?? '',
isAdmin: json['is_admin'] as bool? ?? false,
isTwoFactorEnabled: json['is_two_factor_enabled'] as bool? ?? false,
tariffId: json['tariff_id'] as String?,
calendarProvider: json['calendar_provider'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'email': email,
'name': name,
'is_admin': isAdmin,
'is_two_factor_enabled': isTwoFactorEnabled,
'tariff_id': tariffId,
'calendar_provider': calendarProvider,
'created_at': createdAt.toIso8601String(),
};
}
class TwoFactorRequest {
final String code;
TwoFactorRequest({required this.code});
Map<String, dynamic> toJson() => {'code': code};
}
class ForgotPasswordRequest {
final String email;
ForgotPasswordRequest({required this.email});
Map<String, dynamic> toJson() => {'email': email};
}
class ResetPasswordRequest {
final String token;
final String password;
ResetPasswordRequest({required this.token, required this.password});
Map<String, dynamic> toJson() => {
'token': token,
'password': password,
};
}
+134
View File
@@ -0,0 +1,134 @@
class Vote {
final String id;
final String ideaId;
final String userId;
final bool isUpvote;
final DateTime createdAt;
Vote({
required this.id,
required this.ideaId,
required this.userId,
required this.isUpvote,
required this.createdAt,
});
factory Vote.fromJson(Map<String, dynamic> json) => Vote(
id: json['id'] as String,
ideaId: json['idea_id'] as String,
userId: json['user_id'] as String,
isUpvote: json['is_upvote'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
class Comment {
final String id;
final String ideaId;
final String userId;
final String userName;
final String body;
final String? parentId;
final List<Comment> replies;
final DateTime createdAt;
final DateTime updatedAt;
Comment({
required this.id,
required this.ideaId,
required this.userId,
this.userName = '',
required this.body,
this.parentId,
this.replies = const [],
required this.createdAt,
required this.updatedAt,
});
factory Comment.fromJson(Map<String, dynamic> json) => Comment(
id: json['id'] as String,
ideaId: json['idea_id'] as String,
userId: json['user_id'] as String,
userName: json['user_name'] as String? ?? '',
body: json['body'] as String,
parentId: json['parent_id'] as String?,
replies: (json['replies'] as List<dynamic>? ?? [])
.map((e) => Comment.fromJson(e as Map<String, dynamic>))
.toList(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
class CreateCommentRequest {
final String body;
final String? parentId;
CreateCommentRequest({required this.body, this.parentId});
Map<String, dynamic> toJson() => {
'body': body,
if (parentId != null) 'parent_id': parentId,
};
}
class Activity {
final String id;
final String ideaId;
final String userId;
final String userName;
final String action;
final String? description;
final DateTime createdAt;
Activity({
required this.id,
required this.ideaId,
required this.userId,
this.userName = '',
required this.action,
this.description,
required this.createdAt,
});
factory Activity.fromJson(Map<String, dynamic> json) => Activity(
id: json['id'] as String,
ideaId: json['idea_id'] as String,
userId: json['user_id'] as String,
userName: json['user_name'] as String? ?? '',
action: json['action'] as String,
description: json['description'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
class AppNotification {
final String id;
final String userId;
final String title;
final String body;
final bool isRead;
final String? ideaId;
final DateTime createdAt;
AppNotification({
required this.id,
required this.userId,
required this.title,
required this.body,
this.isRead = false,
this.ideaId,
required this.createdAt,
});
factory AppNotification.fromJson(Map<String, dynamic> json) =>
AppNotification(
id: json['id'] as String,
userId: json['user_id'] as String,
title: json['title'] as String,
body: json['body'] as String? ?? '',
isRead: json['is_read'] as bool? ?? false,
ideaId: json['idea_id'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
+120
View File
@@ -0,0 +1,120 @@
class Idea {
final String id;
final String title;
final String description;
final String? funnelStatus;
final String? workspaceId;
final String userId;
final String userName;
final int upvotes;
final int downvotes;
final int commentCount;
final DateTime createdAt;
final DateTime updatedAt;
Idea({
required this.id,
required this.title,
required this.description,
this.funnelStatus,
this.workspaceId,
required this.userId,
this.userName = '',
this.upvotes = 0,
this.downvotes = 0,
this.commentCount = 0,
required this.createdAt,
required this.updatedAt,
});
factory Idea.fromJson(Map<String, dynamic> json) => Idea(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String? ?? '',
funnelStatus: json['funnel_status'] as String?,
workspaceId: json['workspace_id'] as String?,
userId: json['user_id'] as String? ?? '',
userName: json['user_name'] as String? ?? '',
upvotes: json['upvotes'] as int? ?? 0,
downvotes: json['downvotes'] as int? ?? 0,
commentCount: json['comment_count'] as int? ?? 0,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'funnel_status': funnelStatus,
'workspace_id': workspaceId,
'user_id': userId,
'user_name': userName,
'upvotes': upvotes,
'downvotes': downvotes,
'comment_count': commentCount,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
Idea copyWith({
String? title,
String? description,
String? funnelStatus,
int? upvotes,
int? downvotes,
int? commentCount,
}) =>
Idea(
id: id,
title: title ?? this.title,
description: description ?? this.description,
funnelStatus: funnelStatus ?? this.funnelStatus,
workspaceId: workspaceId,
userId: userId,
userName: userName,
upvotes: upvotes ?? this.upvotes,
downvotes: downvotes ?? this.downvotes,
commentCount: commentCount ?? this.commentCount,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
class CreateIdeaRequest {
final String title;
final String description;
final String? workspaceId;
CreateIdeaRequest({
required this.title,
required this.description,
this.workspaceId,
});
Map<String, dynamic> toJson() => {
'title': title,
'description': description,
'workspace_id': workspaceId,
};
}
class UpdateIdeaRequest {
final String? title;
final String? description;
UpdateIdeaRequest({this.title, this.description});
Map<String, dynamic> toJson() => {
if (title != null) 'title': title,
if (description != null) 'description': description,
};
}
class FunnelTransitionRequest {
final String status;
FunnelTransitionRequest({required this.status});
Map<String, dynamic> toJson() => {'status': status};
}
+39
View File
@@ -0,0 +1,39 @@
class Tariff {
final String id;
final String name;
final double price;
final String currency;
final Map<String, dynamic> features;
final bool isActive;
final DateTime createdAt;
Tariff({
required this.id,
required this.name,
required this.price,
this.currency = 'RUB',
required this.features,
this.isActive = true,
required this.createdAt,
});
factory Tariff.fromJson(Map<String, dynamic> json) => Tariff(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
currency: json['currency'] as String? ?? 'RUB',
features: json['features'] as Map<String, dynamic>? ?? {},
isActive: json['is_active'] as bool? ?? true,
createdAt: DateTime.parse(json['created_at'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'price': price,
'currency': currency,
'features': features,
'is_active': isActive,
'created_at': createdAt.toIso8601String(),
};
}
+78
View File
@@ -0,0 +1,78 @@
class VoiceSession {
final String id;
final String userId;
final String? title;
final String status;
final int messageCount;
final DateTime createdAt;
final DateTime updatedAt;
VoiceSession({
required this.id,
required this.userId,
this.title,
required this.status,
this.messageCount = 0,
required this.createdAt,
required this.updatedAt,
});
factory VoiceSession.fromJson(Map<String, dynamic> json) => VoiceSession(
id: json['id'] as String,
userId: json['user_id'] as String,
title: json['title'] as String?,
status: json['status'] as String? ?? 'active',
messageCount: json['message_count'] as int? ?? 0,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
class VoiceCommand {
final String id;
final String sessionId;
final String command;
final String? result;
final DateTime createdAt;
VoiceCommand({
required this.id,
required this.sessionId,
required this.command,
this.result,
required this.createdAt,
});
factory VoiceCommand.fromJson(Map<String, dynamic> json) => VoiceCommand(
id: json['id'] as String,
sessionId: json['session_id'] as String,
command: json['command'] as String,
result: json['result'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
class TranscribeRequest {
final String audioBase64;
final String? sessionId;
TranscribeRequest({required this.audioBase64, this.sessionId});
Map<String, dynamic> toJson() => {
'audio_base64': audioBase64,
if (sessionId != null) 'session_id': sessionId,
};
}
class TranscribeResponse {
final String text;
final String? sessionId;
TranscribeResponse({required this.text, this.sessionId});
factory TranscribeResponse.fromJson(Map<String, dynamic> json) =>
TranscribeResponse(
text: json['text'] as String,
sessionId: json['session_id'] as String?,
);
}
+84
View File
@@ -0,0 +1,84 @@
class Workspace {
final String id;
final String name;
final String type;
final String? description;
final String ownerId;
final int memberCount;
final DateTime createdAt;
Workspace({
required this.id,
required this.name,
required this.type,
this.description,
required this.ownerId,
this.memberCount = 0,
required this.createdAt,
});
factory Workspace.fromJson(Map<String, dynamic> json) => Workspace(
id: json['id'] as String,
name: json['name'] as String,
type: json['type'] as String? ?? 'personal',
description: json['description'] as String?,
ownerId: json['owner_id'] as String? ?? '',
memberCount: json['member_count'] as int? ?? 0,
createdAt: DateTime.parse(json['created_at'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'type': type,
'description': description,
'owner_id': ownerId,
'member_count': memberCount,
'created_at': createdAt.toIso8601String(),
};
bool get isPersonal => type == 'personal';
bool get isTeam => type == 'team';
}
class WorkspaceMember {
final String id;
final String userId;
final String userName;
final String userEmail;
final String role;
final DateTime joinedAt;
WorkspaceMember({
required this.id,
required this.userId,
this.userName = '',
this.userEmail = '',
required this.role,
required this.joinedAt,
});
factory WorkspaceMember.fromJson(Map<String, dynamic> json) =>
WorkspaceMember(
id: json['id'] as String,
userId: json['user_id'] as String? ?? '',
userName: json['user_name'] as String? ?? '',
userEmail: json['user_email'] as String? ?? '',
role: json['role'] as String? ?? 'member',
joinedAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: DateTime.now(),
);
}
class CreateWorkspaceRequest {
final String name;
final String? description;
CreateWorkspaceRequest({required this.name, this.description});
Map<String, dynamic> toJson() => {
'name': name,
'description': description,
};
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/client.dart';
import '../core/api/endpoints.dart';
import '../core/api/exceptions.dart';
import '../models/auth.dart';
enum AuthStatus { initial, authenticated, unauthenticated, loading }
class AuthState {
final AuthStatus status;
final User? user;
final String? error;
final bool needsTwoFactor;
AuthState({
this.status = AuthStatus.initial,
this.user,
this.error,
this.needsTwoFactor = false,
});
AuthState copyWith({
AuthStatus? status,
User? user,
String? error,
bool? needsTwoFactor,
}) =>
AuthState(
status: status ?? this.status,
user: user ?? this.user,
error: error,
needsTwoFactor: needsTwoFactor ?? this.needsTwoFactor,
);
}
class AuthNotifier extends StateNotifier<AuthState> {
final ApiClient _api;
AuthNotifier(this._api) : super(AuthState()) {
_checkAuth();
}
Future<void> _checkAuth() async {
final token = await _api.getAccessToken();
if (token != null) {
state = state.copyWith(status: AuthStatus.authenticated);
} else {
state = state.copyWith(status: AuthStatus.unauthenticated);
}
}
Future<void> login(String email, String password) async {
state = state.copyWith(status: AuthStatus.loading, error: null);
try {
final response = await _api.post(Endpoints.login, data: {
'email': email,
'password': password,
});
final authResponse = AuthResponse.fromJson(
response.data as Map<String, dynamic>);
if (authResponse.user.isTwoFactorEnabled) {
state = state.copyWith(
status: AuthStatus.unauthenticated,
needsTwoFactor: true,
);
return;
}
await _api.saveTokens(
authResponse.accessToken, authResponse.refreshToken);
state = state.copyWith(
status: AuthStatus.authenticated,
user: authResponse.user,
);
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.unauthenticated,
error: e.message,
);
}
}
Future<void> register(String email, String password, String name) async {
state = state.copyWith(status: AuthStatus.loading, error: null);
try {
final response = await _api.post(Endpoints.register, data: {
'email': email,
'password': password,
'name': name,
});
final authResponse = AuthResponse.fromJson(
response.data as Map<String, dynamic>);
await _api.saveTokens(
authResponse.accessToken, authResponse.refreshToken);
state = state.copyWith(
status: AuthStatus.authenticated,
user: authResponse.user,
);
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.unauthenticated,
error: e.message,
);
}
}
Future<void> verifyTwoFactor(String code) async {
state = state.copyWith(status: AuthStatus.loading, error: null);
try {
final response = await _api.post(Endpoints.twoFactor, data: {
'code': code,
});
final authResponse = AuthResponse.fromJson(
response.data as Map<String, dynamic>);
await _api.saveTokens(
authResponse.accessToken, authResponse.refreshToken);
state = state.copyWith(
status: AuthStatus.authenticated,
user: authResponse.user,
needsTwoFactor: false,
);
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.unauthenticated,
error: e.message,
);
}
}
Future<void> logout() async {
await _api.clearTokens();
state = AuthState(status: AuthStatus.unauthenticated);
}
Future<void> forgotPassword(String email) async {
state = state.copyWith(status: AuthStatus.loading, error: null);
try {
await _api.post(Endpoints.forgotPassword, data: {'email': email});
state = state.copyWith(status: AuthStatus.unauthenticated);
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.unauthenticated, error: e.message);
}
}
Future<void> resetPassword(String token, String password) async {
state = state.copyWith(status: AuthStatus.loading, error: null);
try {
await _api.post(Endpoints.resetPassword, data: {
'token': token,
'password': password,
});
state = state.copyWith(status: AuthStatus.unauthenticated);
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.unauthenticated, error: e.message);
}
}
Future<String?> getOAuthUrl(String provider) async {
try {
final response = await _api.get('${Endpoints.oauth}/$provider');
return response.data['url'] as String?;
} catch (_) {
return null;
}
}
void clearError() {
state = state.copyWith(error: null);
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final api = ref.read(apiClientProvider);
return AuthNotifier(api);
});
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/disk.dart';
import '../core/api/client.dart';
class DiskState {
final List<String> availableProviders;
final List<Map<String, dynamic>> connectedProviders;
final bool isLoading;
final String? error;
DiskState({
this.availableProviders = const [],
this.connectedProviders = const [],
this.isLoading = false,
this.error,
});
DiskState copyWith({
List<String>? availableProviders,
List<Map<String, dynamic>>? connectedProviders,
bool? isLoading,
String? error,
}) =>
DiskState(
availableProviders: availableProviders ?? this.availableProviders,
connectedProviders: connectedProviders ?? this.connectedProviders,
isLoading: isLoading ?? this.isLoading,
error: error,
);
bool isConnected(String provider) =>
connectedProviders.any((p) => p['provider'] == provider);
}
class DiskNotifier extends StateNotifier<DiskState> {
final DiskApi _api;
DiskNotifier(this._api) : super(DiskState());
Future<void> load() async {
state = state.copyWith(isLoading: true, error: null);
try {
final providers = await _api.getProviders();
final status = await _api.getStatus();
final connected = (status['connected'] as List<dynamic>? ?? [])
.map((e) => e as Map<String, dynamic>)
.toList();
state = state.copyWith(
availableProviders: providers,
connectedProviders: connected,
isLoading: false,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<bool> connect(String provider, String code) async {
final ok = await _api.connect(provider, code);
if (ok) await load();
return ok;
}
Future<bool> disconnect(String provider) async {
final ok = await _api.disconnect(provider);
if (ok) {
state = state.copyWith(
connectedProviders: state.connectedProviders
.where((p) => p['provider'] != provider)
.toList(),
);
}
return ok;
}
Future<String?> getOAuthUrl(String provider) =>
_api.getOAuthUrl(provider);
}
final diskProvider = StateNotifierProvider<DiskNotifier, DiskState>((ref) {
final api = ref.read(apiClientProvider);
return DiskNotifier(DiskApi(api));
});
+269
View File
@@ -0,0 +1,269 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/client.dart';
import '../core/api/endpoints.dart';
import '../core/api/exceptions.dart';
import '../models/idea.dart';
import '../models/collaboration.dart';
class IdeasState {
final List<Idea> ideas;
final Idea? selectedIdea;
final bool isLoading;
final String? error;
final String viewMode;
IdeasState({
this.ideas = const [],
this.selectedIdea,
this.isLoading = false,
this.error,
this.viewMode = 'list',
});
IdeasState copyWith({
List<Idea>? ideas,
Idea? selectedIdea,
bool? isLoading,
String? error,
String? viewMode,
}) =>
IdeasState(
ideas: ideas ?? this.ideas,
selectedIdea: selectedIdea ?? this.selectedIdea,
isLoading: isLoading ?? this.isLoading,
error: error,
viewMode: viewMode ?? this.viewMode,
);
}
class IdeasNotifier extends StateNotifier<IdeasState> {
final ApiClient _api;
IdeasNotifier(this._api) : super(IdeasState());
Future<void> loadIdeas({String? workspaceId}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final params = <String, dynamic>{};
if (workspaceId != null) params['workspace_id'] = workspaceId;
final response = await _api.get(Endpoints.ideas, params: params);
final list = (response.data as List<dynamic>)
.map((e) => Idea.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(ideas: list, isLoading: false);
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
}
}
Future<void> loadIdea(String id) async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _api.get(Endpoints.idea(id));
final idea =
Idea.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(selectedIdea: idea, isLoading: false);
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
}
}
Future<bool> createIdea(CreateIdeaRequest request) async {
try {
final response = await _api.post(Endpoints.ideas, data: request.toJson());
final idea =
Idea.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(
ideas: [idea, ...state.ideas],
selectedIdea: idea,
);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
Future<bool> updateIdea(String id, UpdateIdeaRequest request) async {
try {
final response =
await _api.patch(Endpoints.idea(id), data: request.toJson());
final updated =
Idea.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(
ideas: state.ideas.map((i) => i.id == id ? updated : i).toList(),
selectedIdea: updated,
);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
Future<bool> deleteIdea(String id) async {
try {
await _api.delete(Endpoints.idea(id));
state = state.copyWith(
ideas: state.ideas.where((i) => i.id != id).toList(),
selectedIdea: null,
);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
Future<bool> updateFunnel(String ideaId, String status) async {
try {
final response = await _api.post(Endpoints.ideaFunnel(ideaId), data: {
'status': status,
});
final updated =
Idea.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(
ideas: state.ideas.map((i) => i.id == ideaId ? updated : i).toList(),
selectedIdea: updated,
);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
void setViewMode(String mode) {
state = state.copyWith(viewMode: mode);
}
void clearError() {
state = state.copyWith(error: null);
}
}
final ideasProvider =
StateNotifierProvider<IdeasNotifier, IdeasState>((ref) {
final api = ref.read(apiClientProvider);
return IdeasNotifier(api);
});
class IdeaCommentsState {
final List<Comment> comments;
final bool isLoading;
final String? error;
IdeaCommentsState({
this.comments = const [],
this.isLoading = false,
this.error,
});
IdeaCommentsState copyWith({
List<Comment>? comments,
bool? isLoading,
String? error,
}) =>
IdeaCommentsState(
comments: comments ?? this.comments,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
class IdeaCommentsNotifier extends StateNotifier<IdeaCommentsState> {
final ApiClient _api;
final String ideaId;
IdeaCommentsNotifier(this._api, this.ideaId) : super(IdeaCommentsState());
Future<void> loadComments() async {
state = state.copyWith(isLoading: true);
try {
final response = await _api.get(Endpoints.ideaComments(ideaId));
final list = (response.data as List<dynamic>)
.map((e) => Comment.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(comments: list, isLoading: false);
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
}
}
Future<bool> addComment(String body, {String? parentId}) async {
try {
final response = await _api.post(Endpoints.ideaComments(ideaId), data: {
'body': body,
if (parentId != null) 'parent_id': parentId,
});
final comment =
Comment.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(comments: [...state.comments, comment]);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
Future<void> toggleVote(String ideaId) async {
try {
await _api.post(Endpoints.ideaVote(ideaId));
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
}
}
}
final ideaCommentsProvider =
StateNotifierProvider.family<IdeaCommentsNotifier, IdeaCommentsState, String>(
(ref, ideaId) {
final api = ref.read(apiClientProvider);
return IdeaCommentsNotifier(api, ideaId);
});
class IdeaActivityState {
final List<Activity> activities;
final bool isLoading;
IdeaActivityState({
this.activities = const [],
this.isLoading = false,
});
IdeaActivityState copyWith({
List<Activity>? activities,
bool? isLoading,
}) =>
IdeaActivityState(
activities: activities ?? this.activities,
isLoading: isLoading ?? this.isLoading,
);
}
class IdeaActivityNotifier extends StateNotifier<IdeaActivityState> {
final ApiClient _api;
final String ideaId;
IdeaActivityNotifier(this._api, this.ideaId) : super(IdeaActivityState());
Future<void> loadActivities() async {
state = state.copyWith(isLoading: true);
try {
final response = await _api.get(Endpoints.ideaActivity(ideaId));
final list = (response.data as List<dynamic>)
.map((e) => Activity.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(activities: list, isLoading: false);
} catch (_) {
state = state.copyWith(isLoading: false);
}
}
}
final ideaActivityProvider =
StateNotifierProvider.family<IdeaActivityNotifier, IdeaActivityState, String>(
(ref, ideaId) {
final api = ref.read(apiClientProvider);
return IdeaActivityNotifier(api, ideaId);
});
@@ -0,0 +1,120 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/client.dart';
import '../core/api/endpoints.dart';
import '../core/api/exceptions.dart';
import '../models/collaboration.dart';
class NotificationState {
final List<AppNotification> notifications;
final int unreadCount;
final bool isLoading;
final String? error;
NotificationState({
this.notifications = const [],
this.unreadCount = 0,
this.isLoading = false,
this.error,
});
NotificationState copyWith({
List<AppNotification>? notifications,
int? unreadCount,
bool? isLoading,
String? error,
}) =>
NotificationState(
notifications: notifications ?? this.notifications,
unreadCount: unreadCount ?? this.unreadCount,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
class NotificationNotifier extends StateNotifier<NotificationState> {
final ApiClient _api;
NotificationNotifier(this._api) : super(NotificationState());
Future<void> loadNotifications() async {
state = state.copyWith(isLoading: true);
try {
final response = await _api.get(Endpoints.notifications);
final list = (response.data as List<dynamic>)
.map((e) =>
AppNotification.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(notifications: list, isLoading: false);
_updateUnreadCount();
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
}
}
Future<void> loadUnreadCount() async {
try {
final response =
await _api.get(Endpoints.notificationsUnreadCount);
state = state.copyWith(
unreadCount: response.data['count'] as int? ?? 0);
} catch (_) {}
}
Future<void> markAsRead(String id) async {
try {
await _api.patch(Endpoints.notification(id));
state = state.copyWith(
notifications: state.notifications.map((n) {
if (n.id == id) {
return AppNotification(
id: n.id,
userId: n.userId,
title: n.title,
body: n.body,
isRead: true,
ideaId: n.ideaId,
createdAt: n.createdAt,
);
}
return n;
}).toList(),
);
_updateUnreadCount();
} catch (_) {}
}
Future<void> markAllAsRead() async {
try {
await _api.post(Endpoints.notificationsReadAll);
state = state.copyWith(
notifications: state.notifications
.map((n) => AppNotification(
id: n.id,
userId: n.userId,
title: n.title,
body: n.body,
isRead: true,
ideaId: n.ideaId,
createdAt: n.createdAt,
))
.toList(),
unreadCount: 0,
);
} catch (_) {}
}
void _updateUnreadCount() {
state = state.copyWith(
unreadCount: state.notifications.where((n) => !n.isRead).length,
);
}
}
final notificationProvider = StateNotifierProvider<NotificationNotifier, NotificationState>((ref) {
final api = ref.read(apiClientProvider);
return NotificationNotifier(api);
});
final unreadCountProvider = Provider<int>((ref) {
return ref.watch(notificationProvider).unreadCount;
});
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
final themeModeProvider =
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
return ThemeModeNotifier();
});
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
ThemeModeNotifier() : super(ThemeMode.system) {
_load();
}
final _storage = const FlutterSecureStorage();
static const _key = 'theme_mode';
Future<void> _load() async {
final value = await _storage.read(key: _key);
if (value == 'light') state = ThemeMode.light;
if (value == 'dark') state = ThemeMode.dark;
}
Future<void> setTheme(ThemeMode mode) async {
state = mode;
final value = mode == ThemeMode.light
? 'light'
: mode == ThemeMode.dark
? 'dark'
: 'system';
await _storage.write(key: _key, value: value);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/client.dart';
import '../core/api/endpoints.dart';
import '../core/api/exceptions.dart';
import '../models/workspace.dart';
class WorkspaceState {
final List<Workspace> workspaces;
final Workspace? selectedWorkspace;
final List<WorkspaceMember> members;
final bool isLoading;
final String? error;
WorkspaceState({
this.workspaces = const [],
this.selectedWorkspace,
this.members = const [],
this.isLoading = false,
this.error,
});
WorkspaceState copyWith({
List<Workspace>? workspaces,
Workspace? selectedWorkspace,
List<WorkspaceMember>? members,
bool? isLoading,
String? error,
}) =>
WorkspaceState(
workspaces: workspaces ?? this.workspaces,
selectedWorkspace: selectedWorkspace ?? this.selectedWorkspace,
members: members ?? this.members,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
class WorkspaceNotifier extends StateNotifier<WorkspaceState> {
final ApiClient _api;
WorkspaceNotifier(this._api) : super(WorkspaceState());
Future<void> loadWorkspaces() async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _api.get(Endpoints.workspaces);
final list = (response.data as List<dynamic>)
.map((e) => Workspace.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(workspaces: list, isLoading: false);
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
}
}
Future<Workspace?> createWorkspace(CreateWorkspaceRequest request) async {
try {
final response =
await _api.post(Endpoints.workspaces, data: request.toJson());
final ws =
Workspace.fromJson(response.data as Map<String, dynamic>);
state = state.copyWith(workspaces: [...state.workspaces, ws]);
return ws;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return null;
}
}
Future<void> selectWorkspace(Workspace workspace) async {
state = state.copyWith(selectedWorkspace: workspace);
await loadMembers(workspace.id);
}
Future<void> loadMembers(String workspaceId) async {
try {
final response =
await _api.get(Endpoints.workspaceMembers(workspaceId));
final list = (response.data as List<dynamic>)
.map((e) => WorkspaceMember.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(members: list);
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
}
}
Future<bool> addMember(String workspaceId, String email, String role) async {
try {
await _api.post(Endpoints.workspaceMembers(workspaceId), data: {
'email': email,
'role': role,
});
await loadMembers(workspaceId);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
Future<bool> removeMember(String workspaceId, String userId) async {
try {
await _api.delete(Endpoints.workspaceMember(workspaceId, userId));
state = state.copyWith(
members: state.members.where((m) => m.userId != userId).toList(),
);
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);
return false;
}
}
void clearError() {
state = state.copyWith(error: null);
}
}
final workspaceProvider =
StateNotifierProvider<WorkspaceNotifier, WorkspaceState>((ref) {
final api = ref.read(apiClientProvider);
return WorkspaceNotifier(api);
});
+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,
),
),
],
),
),
);
}
}