feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user