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