forked from eatvio/eatvio_app
Compare commits
127 Commits
issue-712
...
personalDi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72c40d8ee | ||
|
|
6ed7faed15 | ||
|
|
16fccaea8e | ||
|
|
a723673d23 | ||
|
|
89f697f7a6 | ||
| d883c9e19a | |||
|
|
884830f232 | ||
|
|
7b297f954e | ||
|
|
dbd92a65ea | ||
|
|
f3af743487 | ||
|
|
c95f1988ae | ||
|
|
35a5e6d6be | ||
|
|
977b7150fe | ||
| 5b81014e72 | |||
|
|
563d84ca12 | ||
| 06cafb4257 | |||
|
|
a34cb01c1f | ||
|
|
8799375880 | ||
|
|
fb5a0d52bc | ||
|
|
2e3172bc45 | ||
|
|
41313f09af | ||
|
|
ce0099c385 | ||
|
|
14e4d7973e | ||
|
|
a138707111 | ||
|
|
4c7dd4a12a | ||
|
|
c2eda628dd | ||
|
|
53f2b78dfa | ||
|
|
ed612aa019 | ||
|
|
1f4c450ce7 | ||
|
|
1841199291 | ||
|
|
4c5532ff32 | ||
|
|
c1cbf1658b | ||
|
|
e7ab43c845 | ||
|
|
b9136a80de | ||
| bb68ea963c | |||
|
|
228ea7c902 | ||
| 8c85d1837a | |||
|
|
1d8f414672 | ||
| 49453c8b31 | |||
|
|
138e2c1073 | ||
| eb39ae5e7c | |||
|
|
250d9614e3 | ||
| b9b06f11a6 | |||
|
|
12ca205515 | ||
|
|
d8e7464c37 | ||
|
|
e0c90bcb21 | ||
| 4a96390839 | |||
| a3b25270ac | |||
|
|
c72b3e77c6 | ||
| 35f2c0f30a | |||
|
|
9e43be5b4e | ||
| 4db5dda3ce | |||
| 87d5e0bc23 | |||
| fa3cf297a3 | |||
| 7b08560257 | |||
| efd6cca87d | |||
|
|
05bf90a3cf | ||
|
|
feee814bbb | ||
|
|
d871fdc084 | ||
|
|
e865a11d14 | ||
|
|
b242eaa027 | ||
|
|
e073bd3f97 | ||
| 9281213ea1 | |||
|
|
d0ac156307 | ||
|
|
4a9d45a18d | ||
|
|
65fabefdb9 | ||
|
|
f573bb28f8 | ||
|
|
20aca4b9a4 | ||
|
|
5354c3f517 | ||
| 17dbc1d5bf | |||
|
|
09a00a41ad | ||
|
|
24675714e0 | ||
|
|
21492a1ad9 | ||
|
|
239ff675db | ||
|
|
fa6c2f9000 | ||
|
|
ae0c3a53be | ||
| 21555bfb10 | |||
|
|
f90f1d3284 | ||
| f9fed539c2 | |||
|
|
654edc5457 | ||
| 928a55ae93 | |||
|
|
b2433b87b6 | ||
|
|
183fa012d2 | ||
|
|
9ac576221a | ||
|
|
5013ffe06b | ||
| 2993c05dd1 | |||
|
|
79de6ee6fa | ||
|
|
b0d2d86a18 | ||
|
|
03448ebd2d | ||
|
|
f35cdd26a3 | ||
|
|
5ee86ddc92 | ||
|
|
fceaf3d162 | ||
|
|
46841fb502 | ||
|
|
8e72350152 | ||
|
|
8816b67ff5 | ||
|
|
ca3bd7cc0d | ||
|
|
782f75d26b | ||
|
|
c88b010cc5 | ||
|
|
79dc590beb | ||
|
|
517e493b5e | ||
|
|
1bc0ab3237 | ||
|
|
a8994eecf4 | ||
|
|
8d3ae15112 | ||
| 84cdf34859 | |||
|
|
6b7b1f2d18 | ||
|
|
82bc51e45c | ||
|
|
77666aca0e | ||
|
|
16430af989 | ||
|
|
8c2cd5eedd | ||
| a31f721d81 | |||
| 1b93f5c287 | |||
| 7b8a5d14d7 | |||
| f94fd6f358 | |||
| a9ad6aedd0 | |||
| 83dd53351b | |||
| f269ab59cc | |||
|
|
996f2ecd70 | ||
|
|
19787f459e | ||
|
|
7e64b24a8c | ||
|
|
a07d085ba7 | ||
|
|
5616bedd25 | ||
|
|
0a3d9e577d | ||
|
|
55e3abec99 | ||
| 0d4ec9390d | |||
| a16e78f804 | |||
| c021cdf2c1 | |||
| 851c300622 |
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# - name: Build apk
|
||||
# run: flutter build apk
|
||||
# some test
|
||||
# some test 2
|
||||
|
||||
# - name: Rename and move apk
|
||||
# run: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Build Test Flight"
|
||||
name: 'Build Test Flight'
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
|
||||
- name: Checkout env repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
@@ -39,8 +39,8 @@ jobs:
|
||||
run: |
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" /Users/gitea_runner_ci/Library/Keychains/login.keychain-db
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
|
||||
KEYCHAIN_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
|
||||
- name: Setup API key
|
||||
run: |
|
||||
echo "${API_APPSTORE_KEY}" | tr -d '\n' | base64 --decode > ios/AuthKey.p8
|
||||
@@ -66,9 +66,9 @@ jobs:
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
||||
FASTLANE_USER: eatvio2023@icloud.com
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||
|
||||
167
.kilocode/rules/memory-bank-instructions.md
Normal file
167
.kilocode/rules/memory-bank-instructions.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Memory Bank
|
||||
|
||||
I am an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
|
||||
|
||||
When I start a task, I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization.
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
The Memory Bank consists of core files and optional context files, all in Markdown format.
|
||||
|
||||
### Core Files (Required)
|
||||
1. `brief.md`
|
||||
This file is created and maintained manually by the developer. Don't edit this file directly but suggest to user to update it if it can be improved.
|
||||
- Foundation document that shapes all other files
|
||||
- Created at project start if it doesn't exist
|
||||
- Defines core requirements and goals
|
||||
- Source of truth for project scope
|
||||
|
||||
2. `product.md`
|
||||
- Why this project exists
|
||||
- Problems it solves
|
||||
- How it should work
|
||||
- User experience goals
|
||||
|
||||
3. `context.md`
|
||||
This file should be short and factual, not creative or speculative.
|
||||
- Current work focus
|
||||
- Recent changes
|
||||
- Next steps
|
||||
|
||||
4. `architecture.md`
|
||||
- System architecture
|
||||
- Source Code paths
|
||||
- Key technical decisions
|
||||
- Design patterns in use
|
||||
- Component relationships
|
||||
- Critical implementation paths
|
||||
|
||||
5. `tech.md`
|
||||
- Technologies used
|
||||
- Development setup
|
||||
- Technical constraints
|
||||
- Dependencies
|
||||
- Tool usage patterns
|
||||
|
||||
### Additional Files
|
||||
Create additional files/folders within memory-bank/ when they help organize:
|
||||
- `tasks.md` - Documentation of repetitive tasks and their workflows
|
||||
- Complex feature documentation
|
||||
- Integration specifications
|
||||
- API documentation
|
||||
- Testing strategies
|
||||
- Deployment procedures
|
||||
|
||||
## Core workflows
|
||||
|
||||
### Memory Bank Initialization
|
||||
|
||||
The initialization step is CRITICALLY IMPORTANT and must be done with extreme thoroughness as it defines all future effectiveness of the Memory Bank. This is the foundation upon which all future interactions will be built.
|
||||
|
||||
When user requests initialization of the memory bank (command `initialize memory bank`), I'll perform an exhaustive analysis of the project, including:
|
||||
- All source code files and their relationships
|
||||
- Configuration files and build system setup
|
||||
- Project structure and organization patterns
|
||||
- Documentation and comments
|
||||
- Dependencies and external integrations
|
||||
- Testing frameworks and patterns
|
||||
|
||||
I must be extremely thorough during initialization, spending extra time and effort to build a comprehensive understanding of the project. A high-quality initialization will dramatically improve all future interactions, while a rushed or incomplete initialization will permanently limit my effectiveness.
|
||||
|
||||
After initialization, I will ask the user to read through the memory bank files and verify product description, used technologies and other information. I should provide a summary of what I've understood about the project to help the user verify the accuracy of the memory bank files. I should encourage the user to correct any misunderstandings or add missing information, as this will significantly improve future interactions.
|
||||
|
||||
### Memory Bank Update
|
||||
|
||||
Memory Bank updates occur when:
|
||||
1. Discovering new project patterns
|
||||
2. After implementing significant changes
|
||||
3. When user explicitly requests with the phrase **update memory bank** (MUST review ALL files)
|
||||
4. When context needs clarification
|
||||
|
||||
If I notice significant changes that should be preserved but the user hasn't explicitly requested an update, I should suggest: "Would you like me to update the memory bank to reflect these changes?"
|
||||
|
||||
To execute Memory Bank update, I will:
|
||||
|
||||
1. Review ALL project files
|
||||
2. Document current state
|
||||
3. Document Insights & Patterns
|
||||
4. If requested with additional context (e.g., "update memory bank using information from @/Makefile"), focus special attention on that source
|
||||
|
||||
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on context.md as it tracks current state.
|
||||
|
||||
### Add Task
|
||||
|
||||
When user completes a repetitive task (like adding support for a new model version) and wants to document it for future reference, they can request: **add task** or **store this as a task**.
|
||||
|
||||
This workflow is designed for repetitive tasks that follow similar patterns and require editing the same files. Examples include:
|
||||
- Adding support for new AI model versions
|
||||
- Implementing new API endpoints following established patterns
|
||||
- Adding new features that follow existing architecture
|
||||
|
||||
Tasks are stored in the file `tasks.md` in the memory bank folder. The file is optional and can be empty. The file can store many tasks.
|
||||
|
||||
To execute Add Task workflow:
|
||||
|
||||
1. Create or update `tasks.md` in the memory bank folder
|
||||
2. Document the task with:
|
||||
- Task name and description
|
||||
- Files that need to be modified
|
||||
- Step-by-step workflow followed
|
||||
- Important considerations or gotchas
|
||||
- Example of the completed implementation
|
||||
3. Include any context that was discovered during task execution but wasn't previously documented
|
||||
|
||||
Example task entry:
|
||||
```markdown
|
||||
## Add New Model Support
|
||||
**Last performed:** [date]
|
||||
**Files to modify:**
|
||||
- `/providers/gemini.md` - Add model to documentation
|
||||
- `/src/providers/gemini-config.ts` - Add model configuration
|
||||
- `/src/constants/models.ts` - Add to model list
|
||||
- `/tests/providers/gemini.test.ts` - Add test cases
|
||||
|
||||
**Steps:**
|
||||
1. Add model configuration with proper token limits
|
||||
2. Update documentation with model capabilities
|
||||
3. Add to constants file for UI display
|
||||
4. Write tests for new model configuration
|
||||
|
||||
**Important notes:**
|
||||
- Check Google's documentation for exact token limits
|
||||
- Ensure backward compatibility with existing configurations
|
||||
- Test with actual API calls before committing
|
||||
```
|
||||
|
||||
### Regular Task Execution
|
||||
|
||||
In the beginning of EVERY task I MUST read ALL memory bank files - this is not optional.
|
||||
|
||||
The memory bank files are located in `.kilocode/rules/memory-bank` folder. If the folder doesn't exist or is empty, I will warn user about potential issues with the memory bank. I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization. I should briefly summarize my understanding of the project to confirm alignment with the user's expectations, like:
|
||||
|
||||
"[Memory Bank: Active] I understand we're building a React inventory system with barcode scanning. Currently implementing the scanner component that needs to work with the backend API."
|
||||
|
||||
When starting a task that matches a documented task in `tasks.md`, I should mention this and follow the documented workflow to ensure no steps are missed.
|
||||
|
||||
If the task was repetitive and might be needed again, I should suggest: "Would you like me to add this task to the memory bank for future reference?"
|
||||
|
||||
In the end of the task, when it seems to be completed, I will update `context.md` accordingly. If the change seems significant, I will suggest to the user: "Would you like me to update memory bank to reflect these changes?" I will not suggest updates for minor changes.
|
||||
|
||||
## Context Window Management
|
||||
|
||||
When the context window fills up during an extended session:
|
||||
1. I should suggest updating the memory bank to preserve the current state
|
||||
2. Recommend starting a fresh conversation/task
|
||||
3. In the new conversation, I will automatically load the memory bank files to maintain continuity
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
Memory Bank is built on Kilo Code's Custom Rules feature, with files stored as standard markdown documents that both the user and I can access.
|
||||
|
||||
## Important Notes
|
||||
|
||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||
|
||||
If I detect inconsistencies between memory bank files, I should prioritize brief.md and note any discrepancies to the user.
|
||||
|
||||
IMPORTANT: I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
|
||||
374
.kilocode/rules/memory-bank/architecture.md
Normal file
374
.kilocode/rules/memory-bank/architecture.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# System Architecture
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Core Directory (`lib/core/`)
|
||||
Central infrastructure and shared components:
|
||||
- **Dependency Injection**: GetIt-based service locator in `locator_service.dart`
|
||||
- **Routing**: GoRouter implementation in `app_router.dart` with deep linking
|
||||
- **Theme**: Material theme with Mulish font family in `theme/theme_helper.dart`
|
||||
- **Networking**: Dio HTTP client with token interceptor
|
||||
- **WebSockets**: Pusher Channels for real-time communication
|
||||
- **State Management**: Flutter BLoC/Cubit pattern
|
||||
|
||||
### Features Directory (`lib/features/`)
|
||||
Feature-based modular architecture with 35+ feature modules:
|
||||
|
||||
**Core Features:**
|
||||
- `auth/` - Authentication (email/password, OAuth)
|
||||
- `feed/` - Content discovery feed
|
||||
- `diary/` - Food diary tracking
|
||||
- `chat/` - Real-time messaging
|
||||
- `consultations/` - Consultation management
|
||||
- `profile/` - User profiles and settings
|
||||
|
||||
**Content Creation:**
|
||||
- `create_recipe/` - Recipe creation
|
||||
- `create_diet/` - Diet plan creation
|
||||
- `create_plan/` - Nutrition plan creation
|
||||
- `blog_post_create/` - Blog post creation
|
||||
|
||||
**User Management:**
|
||||
- `settings/` - User preferences
|
||||
- `user_details/` - User information management
|
||||
- `menu/` - App navigation menu
|
||||
- `bottom_navigation_bar/` - Main navigation
|
||||
|
||||
**Consultant Features:**
|
||||
- `consultant/` - Consultant verification
|
||||
- `consultant_rating/` - Rating system
|
||||
- `finances_and_consultations_archive/` - Financial tracking
|
||||
|
||||
**Supporting Features:**
|
||||
- `notifications/` - Push notifications
|
||||
- `glossary/` - Nutrition glossary
|
||||
- `calendar/` - Calendar integration
|
||||
- `support/` - Customer support
|
||||
- `welcome/` - Onboarding
|
||||
- `update_screen/` - App update management
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Clean Architecture Layers
|
||||
|
||||
Each feature follows a layered structure:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── models/ # Data models (freezed + json_serializable)
|
||||
├── services/ # API services (Retrofit)
|
||||
├── repositories/ # Data repositories
|
||||
├── presentation/ # UI layer
|
||||
│ ├── pages/ # Screen widgets
|
||||
│ ├── widgets/ # Feature-specific widgets
|
||||
│ └── bloc/ # State management (Cubit)
|
||||
└── entities/ # Domain entities
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
**Registration Structure** (`lib/core/di/`):
|
||||
- `app_config_registration.dart` - App configuration
|
||||
- `external_registrations.dart` - External dependencies (Dio, etc.)
|
||||
- `service_registrations.dart` - API services
|
||||
- `repository_registrations.dart` - Data repositories
|
||||
- `cubit_registrations.dart` - State management
|
||||
|
||||
**Service Lifecycle:**
|
||||
- Singleton: Services, Repositories (most common)
|
||||
- Factory: Cubits that need fresh instances
|
||||
- Lazy Singleton: WebSocket manager, core services
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
**BLoC/Cubit Implementation:**
|
||||
- Global state: Registered in `AppConfig.globalBlocProviders`
|
||||
- Feature state: Created per screen/feature
|
||||
- State classes: Freezed unions for type-safe states
|
||||
|
||||
**Global Cubits:**
|
||||
- `NewMessagesCubit` - Message notifications
|
||||
- `NotificationsCubit` - Push notifications
|
||||
- `FeedCubit` - Feed content
|
||||
- `ProfileAvatarCubit` - User profile
|
||||
- `ChatsCubit` - Chat rooms
|
||||
- `ViewsCubit` - Content view tracking
|
||||
- `InternetConnectionCubit` - Network status
|
||||
|
||||
## Data Flow
|
||||
|
||||
### API Integration
|
||||
|
||||
**Base Setup:**
|
||||
- Base URL: Configurable via environment (prod/dev)
|
||||
- HTTP Client: Dio with interceptors
|
||||
- API Services: Retrofit for type-safe endpoints
|
||||
- Authentication: Bearer token in headers
|
||||
|
||||
**Interceptors:**
|
||||
1. `TokenInterceptor` - Adds auth token, handles 401 errors
|
||||
2. `PhoneVerifyErrorInterceptor` - Handles phone verification errors
|
||||
|
||||
### Real-time Communication
|
||||
|
||||
**WebSocket Architecture:**
|
||||
- Library: `dart_pusher_channels`
|
||||
- Manager: `WsManager` (singleton)
|
||||
- Connection: Authenticated via backend endpoint
|
||||
|
||||
**Channels:**
|
||||
1. Private Notifications: `App\Events\BroadcastNotificationCreated`
|
||||
2. Private Messages: `App\Events\ChatMessageUpdated`
|
||||
3. Direct Chat: Room-specific message updates
|
||||
4. Client Typing: Real-time typing indicators
|
||||
|
||||
**Socket ID Storage:**
|
||||
- Persisted via SharedPreferences
|
||||
- Used for authentication and channel subscription
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
**Methods:**
|
||||
1. Email/Password - Traditional credentials
|
||||
2. OAuth Providers:
|
||||
- VK ID (Russian social network)
|
||||
- Yandex ID
|
||||
- OK (Odnoklassniki)
|
||||
- Mail.ru
|
||||
|
||||
**Token Management:**
|
||||
- Storage: SharedPreferences via `TokenStorage`
|
||||
- Refresh: Automatic on 401 response
|
||||
- Logout: Token removal + WebSocket cleanup
|
||||
|
||||
**Post-Authentication:**
|
||||
1. WebSocket initialization
|
||||
2. Profile loading
|
||||
3. Chat room loading
|
||||
4. Notification loading
|
||||
5. Firebase push token registration
|
||||
|
||||
## Navigation Architecture
|
||||
|
||||
### GoRouter Setup
|
||||
|
||||
**Route Structure:**
|
||||
- Shell routes for persistent bottom navigation
|
||||
- Named routes for easy navigation
|
||||
- Deep linking support for content (recipe/blog_post/plan/diet)
|
||||
|
||||
**Route Groups:**
|
||||
- `AuthRoutes` - Login, registration, password reset
|
||||
- `MainNavigationRoutes` - Shell route with bottom nav
|
||||
- `ContentRoutes` - Content viewing screens
|
||||
- `SettingsRoutes` - User settings
|
||||
- `OtherRoutes` - Miscellaneous screens
|
||||
|
||||
**Deep Linking Pattern:**
|
||||
```
|
||||
/content/{type}/{identifier}
|
||||
- /content/recipe/{slug}
|
||||
- /content/blog_post/{id}
|
||||
- /content/plan/{slug}
|
||||
- /content/diet/{slug}
|
||||
```
|
||||
|
||||
### Bottom Navigation
|
||||
|
||||
**Main Sections:**
|
||||
1. My Profile - User profile and content
|
||||
2. Feed - Content discovery
|
||||
3. Diary - Food tracking
|
||||
4. Consultations - Consultation management
|
||||
5. Chat - Messaging
|
||||
6. Notifications - Notification center
|
||||
7. Menu - Settings and more
|
||||
|
||||
**Implementation:**
|
||||
- `BottomNavigationBarWidget` wraps content
|
||||
- No transition animations for tab switches
|
||||
- WebSocket initialization on feed screen
|
||||
|
||||
## Data Persistence
|
||||
|
||||
### Storage Solutions
|
||||
|
||||
**SharedPreferences:**
|
||||
- Authentication token
|
||||
- Socket ID
|
||||
- User preferences
|
||||
|
||||
**Cached Network Images:**
|
||||
- Library: `cached_network_image`
|
||||
- Automatic caching of user avatars, content images
|
||||
|
||||
**Firebase:**
|
||||
- Push notification tokens
|
||||
- Remote configuration
|
||||
- Analytics events
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### Content View Tracking
|
||||
|
||||
**Pattern:**
|
||||
- `ViewsCubit` handles visibility detection
|
||||
- `VisibilityDetector` widget tracks on-screen time
|
||||
- Views sent to backend when 50%+ visible for 2+ seconds
|
||||
- Debounced to prevent excessive API calls
|
||||
|
||||
**Tracked Content:**
|
||||
- Blog posts
|
||||
- Recipes
|
||||
- Diets
|
||||
- Nutrition plans
|
||||
|
||||
### Image Handling
|
||||
|
||||
**Upload Flow:**
|
||||
1. `ImagePicker` for selection
|
||||
2. `FlutterImageCompress` for compression
|
||||
3. MultiPart form data upload via Dio
|
||||
4. Preview via `CachedNetworkImage`
|
||||
|
||||
**Supported Formats:**
|
||||
- User photos (profile, diary)
|
||||
- Content images (recipes, diets, plans)
|
||||
- Chat attachments
|
||||
|
||||
### Search Implementation
|
||||
|
||||
**Debounced Search:**
|
||||
- Library: `easy_debounce`
|
||||
- Delay: 500ms
|
||||
- Min query length: 3 characters
|
||||
- Applied to: Feed, Recipes, Diets, Plans, Consultants
|
||||
|
||||
**Tag-based Filtering:**
|
||||
- Recipe tags for filtering
|
||||
- Modal bottom sheet UI
|
||||
- Persistent selection across sessions
|
||||
|
||||
### Pagination Pattern
|
||||
|
||||
**Implementation:**
|
||||
- Page-based pagination
|
||||
- Scroll detection for next page load
|
||||
- Loading state during fetch
|
||||
- Meta data tracking (current page, last page)
|
||||
|
||||
**Example (FeedCubit):**
|
||||
```dart
|
||||
currentPage = 1
|
||||
lastPage = meta.lastPage
|
||||
loadNextPage() when currentPage < lastPage
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Network Errors
|
||||
|
||||
**401 Unauthorized:**
|
||||
- Auto logout
|
||||
- Token removal
|
||||
- Navigate to auth screen
|
||||
|
||||
**Connection Errors:**
|
||||
- `InternetConnectionCubit` monitors status
|
||||
- Retry mechanisms in repositories
|
||||
- User-friendly error messages
|
||||
|
||||
### State Error Handling
|
||||
|
||||
**Pattern:**
|
||||
```dart
|
||||
sealed class State {
|
||||
const State.loading();
|
||||
const State.loaded({required data});
|
||||
const State.error({required String errorMessage});
|
||||
}
|
||||
```
|
||||
|
||||
**UI Response:**
|
||||
- Loading: Show loaders/skeletons
|
||||
- Error: Show `DataErrorWidget` with retry
|
||||
- Empty: Show empty state messages
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Lazy Loading
|
||||
- Lazy singleton services
|
||||
- On-demand cubit creation
|
||||
- Deferred widget loading
|
||||
|
||||
### Image Optimization
|
||||
- Compression before upload
|
||||
- Cached network images
|
||||
- Thumbnail generation
|
||||
|
||||
### WebSocket Management
|
||||
- Connection reuse
|
||||
- Automatic reconnection
|
||||
- Proper cleanup on logout
|
||||
|
||||
### List Rendering
|
||||
- Pagination for large lists
|
||||
- ListView.builder for efficiency
|
||||
- Visibility detection for tracking
|
||||
|
||||
## Platform-Specific Features
|
||||
|
||||
### Android
|
||||
- Huawei HMS support for Huawei devices
|
||||
- Google FCM for push notifications
|
||||
- ProGuard rules for release builds
|
||||
- QR code scanning via camera
|
||||
|
||||
### iOS
|
||||
- APNs for push notifications
|
||||
- Deep linking configuration
|
||||
- App Store compliance
|
||||
|
||||
### Firebase Integration
|
||||
- Separate configs for Android/iOS
|
||||
- Environment-based configuration
|
||||
- Push notification handling
|
||||
- Analytics tracking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Bearer token authentication
|
||||
- Secure token storage
|
||||
- Automatic session management
|
||||
- OAuth 2.0 for social login
|
||||
|
||||
### Data Protection
|
||||
- HTTPS only communication
|
||||
- Token encryption in storage
|
||||
- Secure WebSocket connections
|
||||
- Privacy policy compliance
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
**Entry Point:**
|
||||
- `lib/main.dart` - App initialization
|
||||
|
||||
**Core Infrastructure:**
|
||||
- `lib/core/app_initializer.dart` - Startup sequence
|
||||
- `lib/core/locator_service.dart` - DI container
|
||||
- `lib/core/app_config.dart` - App configuration
|
||||
- `lib/core/app_router.dart` - Routing setup
|
||||
|
||||
**WebSocket:**
|
||||
- `lib/core/websockets/ws_manager.dart` - Connection management
|
||||
- `lib/core/websockets/ws_service.dart` - API service
|
||||
|
||||
**Authentication:**
|
||||
- `lib/features/auth/sdk/social_auth_facade.dart` - Social auth
|
||||
- `lib/core/token_storage.dart` - Token persistence
|
||||
- `lib/core/interceptors/token_interceptor.dart` - Auth interceptor
|
||||
|
||||
**Environment:**
|
||||
- `lib/core/env_config.dart` - Environment variables
|
||||
- `.env` - Environment file (not in repo)
|
||||
35
.kilocode/rules/memory-bank/brief.md
Normal file
35
.kilocode/rules/memory-bank/brief.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Project Brief
|
||||
|
||||
## Project Name
|
||||
Eatvio - Nutrition & Diet Management Application
|
||||
|
||||
## Project Description
|
||||
A comprehensive mobile application connecting users with nutrition consultants, providing diet planning tools, recipes, food diary tracking, and professional consultation services.
|
||||
|
||||
## Core Features
|
||||
- **Blog Posts**: Educational nutrition content and articles
|
||||
- **Nutrition Consultation**: Professional consultation services with certified nutritionists
|
||||
- **Recipes**: User-generated and professional recipes with nutritional information
|
||||
- **Diets**: Structured diet plans with daily schedules
|
||||
- **Nutrition Plans**: Long-term nutrition planning and tracking
|
||||
|
||||
## Target Audience
|
||||
- Primary: Russian-speaking users interested in nutrition and healthy eating
|
||||
- Secondary: Certified nutrition consultants offering professional services
|
||||
- Market: Russian Federation and Russian-speaking countries
|
||||
|
||||
## Platform
|
||||
- Mobile: iOS and Android (including Huawei devices)
|
||||
- Language: Russian (ru)
|
||||
- Current Version: 1.3.0+79
|
||||
|
||||
## Business Model
|
||||
- Paid consultations with nutritionists
|
||||
- Content marketplace for premium diets and plans
|
||||
- Subscription-based features for consultants
|
||||
|
||||
## Key Success Metrics
|
||||
- User engagement with food diary
|
||||
- Consultation bookings and completion rates
|
||||
- Content creation by consultants
|
||||
- User retention and progress tracking
|
||||
29
.kilocode/rules/memory-bank/context.md
Normal file
29
.kilocode/rules/memory-bank/context.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
- All core files documented
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Ready for development tasks. No active work in progress.
|
||||
|
||||
## Known Issues
|
||||
None documented at initialization.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
- Feature development
|
||||
- Bug fixes
|
||||
- Architecture improvements
|
||||
- Performance optimizations
|
||||
|
||||
## Important Notes
|
||||
- Primary language: Russian (ru)
|
||||
- Target market: Russian users
|
||||
- Multi-platform: Android (including Huawei) and iOS
|
||||
- Environment: Uses `.env` file for configuration (not in repository)
|
||||
- Build system: Requires code generation for models/services
|
||||
73
.kilocode/rules/memory-bank/product.md
Normal file
73
.kilocode/rules/memory-bank/product.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Product Overview
|
||||
|
||||
## Purpose
|
||||
Eatvio is a comprehensive nutrition and diet management mobile application that connects users with nutrition consultants, provides diet planning tools, recipes, and personal food diary tracking.
|
||||
|
||||
## Core Problems It Solves
|
||||
|
||||
### For Regular Users:
|
||||
1. **Diet Management**: Track daily food intake with detailed nutritional information
|
||||
2. **Meal Planning**: Access to recipes and structured diet plans
|
||||
3. **Professional Guidance**: Connect with certified nutrition consultants
|
||||
4. **Progress Tracking**: Monitor weight, body measurements, and dietary habits
|
||||
5. **Knowledge Access**: Learn about nutrition through blog posts and glossary
|
||||
|
||||
### For Nutrition Consultants:
|
||||
1. **Client Management**: Communicate with clients via integrated chat
|
||||
2. **Content Creation**: Create and publish diets, meal plans, recipes, and blog posts
|
||||
3. **Consultation Services**: Offer paid nutrition consultation services
|
||||
4. **Income Tracking**: Monitor earnings and client purchases
|
||||
5. **Professional Presence**: Build profile and gain subscribers
|
||||
|
||||
## How It Works
|
||||
|
||||
### User Journey:
|
||||
1. **Authentication**: Sign up via email/password or social networks (VK, Yandex, OK, Mail.ru)
|
||||
2. **Feed Exploration**: Browse recipes, diets, nutrition plans, blog posts, and consultants
|
||||
3. **Content Interaction**: Like, bookmark, comment on content
|
||||
4. **Diary Tracking**: Log daily meals with photos and nutritional data
|
||||
5. **Consultations**: Book and pay for nutrition consultant services
|
||||
6. **Real-time Chat**: Communicate with consultants through integrated messaging
|
||||
7. **Progress Monitoring**: Track weight and body measurements over time
|
||||
|
||||
### Consultant Journey:
|
||||
1. **Become Consultant**: Apply and get verified as nutrition consultant
|
||||
2. **Content Creation**: Publish professional diets, meal plans, recipes
|
||||
3. **Client Interaction**: Respond to consultations and provide guidance
|
||||
4. **Diary Review**: Review client food diaries and provide feedback
|
||||
5. **Earnings Management**: Track income from consultations and content
|
||||
|
||||
## User Experience Goals
|
||||
|
||||
### Seamless Navigation:
|
||||
- Bottom navigation bar for main sections (Profile, Feed, Diary, Consultations, Chat)
|
||||
- Tab-based feed filtering (Blog posts, Recipes, Diets, Plans, Consultants)
|
||||
- Deep linking support for direct content access
|
||||
|
||||
### Real-time Engagement:
|
||||
- WebSocket-powered chat for instant messaging
|
||||
- Push notifications for messages and updates
|
||||
- Live content updates without app restart
|
||||
|
||||
### Professional Interface:
|
||||
- Clean, modern UI with Mulish font family
|
||||
- Intuitive search and filtering across all content types
|
||||
- Visual progress tracking with charts and gauges
|
||||
|
||||
### Content Discovery:
|
||||
- Tag-based recipe filtering
|
||||
- Search functionality across all content types
|
||||
- Personalized feed based on user interests
|
||||
|
||||
### Offline Capability:
|
||||
- Token persistence for seamless re-authentication
|
||||
- Cached content for better performance
|
||||
- Queue system for offline actions
|
||||
|
||||
## Key Differentiators
|
||||
|
||||
1. **Integrated Platform**: Combines diet tracking, content, and professional consultations
|
||||
2. **Social Features**: Follow consultants, like/bookmark content, engage with community
|
||||
3. **Professional Tools**: Full toolkit for consultants to manage practice
|
||||
4. **Russian Market Focus**: Optimized for Russian users with local payment and auth integrations
|
||||
5. **Multi-platform Support**: Works on Android (including Huawei) and iOS
|
||||
152
.kilocode/rules/memory-bank/tech.md
Normal file
152
.kilocode/rules/memory-bank/tech.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Technology Stack
|
||||
|
||||
## Core Framework
|
||||
- **Flutter**: SDK 3.8.0+
|
||||
- **Dart**: Language version >=3.8.0 <4.0.0
|
||||
- **Version**: 1.3.0+79 (build 79)
|
||||
|
||||
## State Management
|
||||
- **flutter_bloc**: ^9.1.1 - BLoC pattern implementation
|
||||
- **equatable**: ^2.0.7 - Value equality for state objects
|
||||
|
||||
## Data Management
|
||||
- **freezed**: ^3.2.0 - Immutable data classes
|
||||
- **freezed_annotation**: ^3.1.0 - Code generation annotations
|
||||
- **json_annotation**: ^4.9.0 - JSON serialization
|
||||
- **json_serializable**: ^6.10.0 - JSON code generation
|
||||
|
||||
## Networking
|
||||
- **dio**: ^5.9.0 - HTTP client
|
||||
- **retrofit**: ^4.7.1 - Type-safe REST client
|
||||
- **retrofit_generator**: ^10.0.2 - Retrofit code generation
|
||||
- **http**: ^1.5.0 - Additional HTTP support
|
||||
- **http_parser**: ^4.1.2 - HTTP parsing utilities
|
||||
|
||||
## Real-time Communication
|
||||
- **dart_pusher_channels**: ^1.2.3 - WebSocket support via Pusher
|
||||
- **connectivity_plus**: ^6.1.5 - Network connectivity monitoring
|
||||
|
||||
## Navigation & Routing
|
||||
- **go_router**: ^16.1.0 - Declarative routing
|
||||
- Deep linking support for content
|
||||
|
||||
## UI Components
|
||||
- **cached_network_image**: ^3.4.1 - Image caching
|
||||
- **flutter_svg**: ^2.2.0 - SVG rendering
|
||||
- **google_fonts**: ^6.3.0 - Mulish font family
|
||||
- **carousel_slider_plus**: ^7.1.1 - Carousel widgets
|
||||
- **photo_view**: ^0.15.0 - Image viewer
|
||||
- **dismissible_page**: ^1.0.2 - Swipe to dismiss
|
||||
- **badges**: ^3.1.2 - Notification badges
|
||||
- **lottie**: ^3.3.1 - Animation support
|
||||
- **flutter_portal**: ^1.1.4 - Overlay widgets
|
||||
- **dotted_border**: ^3.1.0 - Dotted borders
|
||||
- **flutter_rating_bar**: ^4.0.1 - Rating widgets
|
||||
|
||||
## Forms & Input
|
||||
- **flutter_form_builder**: ^10.2.0 - Form management
|
||||
- **validators**: ^3.0.0 - Input validation
|
||||
- **flutter_typeahead**: ^5.2.0 - Autocomplete
|
||||
- **keyboard_dismisser**: ^3.0.0 - Keyboard management
|
||||
- **flutter_keyboard_visibility**: ^6.0.0 - Keyboard state
|
||||
- **mask_text_input_formatter**: ^2.9.0 - Input masking
|
||||
|
||||
## Media & Files
|
||||
- **image_picker**: ^1.2.0 - Image selection
|
||||
- **flutter_image_compress**: ^2.4.0 - Image compression
|
||||
- **image**: ^4.5.4 - Image manipulation
|
||||
- **file_picker**: ^10.3.1 - File selection
|
||||
- **audioplayers**: ^6.5.0 - Audio playback
|
||||
- **qr_code_scanner_plus**: ^2.0.10+1 - QR code scanning
|
||||
|
||||
## Data Visualization
|
||||
- **syncfusion_flutter_charts**: ^30.2.5 - Chart widgets
|
||||
- **syncfusion_flutter_gauges**: ^30.2.5 - Gauge widgets
|
||||
- **table_calendar**: ^3.2.0 - Calendar widget
|
||||
|
||||
## Storage & Persistence
|
||||
- **shared_preferences**: ^2.5.3 - Local key-value storage
|
||||
- **path_provider**: ^2.1.5 - File system paths
|
||||
- **path**: ^1.9.1 - Path manipulation
|
||||
|
||||
## Firebase
|
||||
- **firebase_core**: ^4.0.0 - Firebase initialization
|
||||
- **firebase_messaging**: ^16.0.0 - Push notifications
|
||||
|
||||
## Analytics & Monitoring
|
||||
- **sentry_flutter**: ^9.6.0 - Error tracking
|
||||
- **appmetrica_plugin**: ^3.3.0 - Analytics (Russian market)
|
||||
|
||||
## Authentication
|
||||
- **vk_id**: ^1.0.3 - VK social login
|
||||
- **flutter_login_yandex**: ^0.0.5 - Yandex ID login
|
||||
- OAuth support for OK and Mail.ru
|
||||
|
||||
## Utilities
|
||||
- **url_launcher**: ^6.3.2 - URL handling
|
||||
- **easy_debounce**: ^2.0.3 - Debouncing
|
||||
- **share_plus**: ^11.1.0 - Content sharing
|
||||
- **flutter_html**: ^3.0.0 - HTML rendering
|
||||
- **webview_flutter**: ^4.13.0 - WebView
|
||||
- **intl**: ^0.20.2 - Internationalization
|
||||
- **collection**: ^1.19.1 - Collection utilities
|
||||
- **uuid**: ^4.5.1 - UUID generation
|
||||
- **visibility_detector**: ^0.4.0+2 - Visibility tracking
|
||||
- **fluttertoast**: ^8.2.12 - Toast messages
|
||||
- **version**: ^3.0.2 - Version management
|
||||
- **package_info_plus**: ^8.3.1 - Package info
|
||||
- **device_info_plus**: ^11.5.0 - Device information
|
||||
- **permission_handler**: ^12.0.1 - Permissions
|
||||
|
||||
## Platform-Specific
|
||||
|
||||
### Android
|
||||
- Huawei HMS Push (6.12.0.300)
|
||||
- Huawei Analytics (6.12.0.300)
|
||||
- Google Play Services
|
||||
- ProGuard for code obfuscation
|
||||
- NDK version 27.0.12077973
|
||||
- Compile SDK: Flutter default
|
||||
- Min SDK: Flutter default
|
||||
- Target SDK: Flutter default
|
||||
|
||||
### iOS
|
||||
- APNs for push notifications
|
||||
- Deep linking support
|
||||
|
||||
## Development Tools
|
||||
- **build_runner**: ^2.7.0 - Code generation
|
||||
- **flutter_native_splash**: ^2.4.6 - Splash screen
|
||||
- **flutter_lints**: ^6.0.0 - Linting
|
||||
- **mockito**: ^5.5.0 - Mocking for tests
|
||||
- **flutter_test**: SDK - Testing framework
|
||||
- **integration_test**: SDK - Integration testing
|
||||
- **flutter_dotenv**: ^5.2.1 - Environment variables
|
||||
|
||||
## Localization
|
||||
- **flutter_localizations**: SDK - Localization
|
||||
- **flutter_intl**: Enabled - Internationalization
|
||||
- Primary language: Russian (ru)
|
||||
|
||||
## Build Configuration
|
||||
- **Environment**: Production/Development/Test modes
|
||||
- **Build types**: Debug, Release, Profile
|
||||
- **Code generation**: Freezed, JSON, Retrofit
|
||||
- **Obfuscation**: ProGuard (Android release)
|
||||
- **Resource optimization**: Shrink resources enabled
|
||||
|
||||
## API Integration Patterns
|
||||
- RESTful API via Retrofit
|
||||
- Bearer token authentication
|
||||
- Interceptor-based auth injection
|
||||
- Automatic 401 handling
|
||||
- Environment-based base URLs
|
||||
- Type-safe API clients
|
||||
|
||||
## Development Workflow
|
||||
1. Code generation: `flutter pub run build_runner build`
|
||||
2. Clean generation: `flutter pub run build_runner build --delete-conflicting-outputs`
|
||||
3. Watch mode: `flutter pub run build_runner watch`
|
||||
4. Environment setup: `.env` file configuration
|
||||
5. Firebase setup: Separate configs per platform
|
||||
6. Testing: Unit tests + Integration tests
|
||||
5
Makefile
5
Makefile
@@ -4,4 +4,7 @@ run-tests:
|
||||
run-analyse:
|
||||
flutter analyze
|
||||
|
||||
run-checks: run-analyse run-tests
|
||||
run-checks: run-analyse run-tests
|
||||
|
||||
build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
450
README.md
450
README.md
@@ -1 +1,449 @@
|
||||
# eatvio_app
|
||||
# Eatvio - Nutrition & Diet Management Application
|
||||
|
||||
## Project Overview
|
||||
|
||||
Eatvio is a comprehensive mobile application connecting users with nutrition consultants, providing diet planning tools, recipes, food diary tracking, and professional consultation services. The application is specifically designed for Russian-speaking users interested in nutrition and healthy eating.
|
||||
|
||||
### Core Features
|
||||
- **Blog Posts**: Educational nutrition content and articles
|
||||
- **Nutrition Consultation**: Professional consultation services with certified nutritionists
|
||||
- **Recipes**: User-generated and professional recipes with nutritional information
|
||||
- **Diets**: Structured diet plans with daily schedules
|
||||
- **Nutrition Plans**: Long-term nutrition planning and tracking
|
||||
|
||||
### Target Audience
|
||||
- **Primary**: Russian-speaking users interested in nutrition and healthy eating
|
||||
- **Secondary**: Certified nutrition consultants offering professional services
|
||||
- **Market**: Russian Federation and Russian-speaking countries
|
||||
|
||||
### Business Model
|
||||
- Paid consultations with nutritionists
|
||||
- Content marketplace for premium diets and plans
|
||||
- Subscription-based features for consultants
|
||||
|
||||
### Platform Information
|
||||
- **Mobile**: iOS and Android (including Huawei devices)
|
||||
- **Language**: Russian (ru)
|
||||
- **Current Version**: 1.3.0+79 (build 79)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Flutter & Dart Versions
|
||||
- **Flutter**: SDK 3.8.0+
|
||||
- **Dart**: Language version >=3.8.0 <4.0.0
|
||||
|
||||
### Platform-Specific Requirements
|
||||
|
||||
#### Android
|
||||
- **NDK Version**: 27.0.12077973
|
||||
- **Huawei HMS Dependencies**:
|
||||
- Huawei Push (6.12.0.300)
|
||||
- Huawei Analytics (6.12.0.300)
|
||||
- **Google Play Services**
|
||||
- **ProGuard** for code obfuscation
|
||||
- **Compile SDK**: Flutter default
|
||||
- **Min SDK**: Flutter default
|
||||
- **Target SDK**: Flutter default
|
||||
|
||||
#### iOS
|
||||
- **APNs** for push notifications
|
||||
- **Deep linking** configuration
|
||||
- **Xcode** version compatible with Flutter requirements
|
||||
|
||||
#### General IDE Requirements
|
||||
- **VS Code** with Flutter/Dart extensions
|
||||
- **Android Studio** with Flutter plugin
|
||||
- **Visual Studio Code** recommended for development
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### .env Setup Process
|
||||
|
||||
1. Copy the environment template:
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
2. Edit the `.env` file with your configuration values
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
#### API URLs
|
||||
- `BASE_URL` - основной URL API (по умолчанию: https://eatvio.ru)
|
||||
- `DEV_BASE_URL` - URL API для разработки (по умолчанию: https://dev2.eatvio.com)
|
||||
|
||||
#### Sentry Configuration
|
||||
- `SENTRY_DSN` - DSN для Sentry мониторинга
|
||||
|
||||
#### Firebase Configuration
|
||||
- `FIREBASE_PROJECT_ID` - ID проекта Firebase
|
||||
- `FIREBASE_DATABASE_URL` - URL базы данных Firebase
|
||||
- `FIREBASE_STORAGE_BUCKET` - bucket для хранения файлов
|
||||
- `FIREBASE_MESSAGING_SENDER_ID` - ID отправителя сообщений
|
||||
|
||||
#### Firebase Android Configuration
|
||||
- `FIREBASE_ANDROID_API_KEY` - API ключ для Android
|
||||
- `FIREBASE_ANDROID_APP_ID` - ID приложения для Android
|
||||
|
||||
#### Firebase iOS Configuration
|
||||
- `FIREBASE_IOS_API_KEY` - API ключ для iOS
|
||||
- `FIREBASE_IOS_APP_ID` - ID приложения для iOS
|
||||
- `FIREBASE_IOS_BUNDLE_ID` - Bundle ID для iOS
|
||||
|
||||
#### App Store URLs
|
||||
- `APP_STORE_URL` - URL App Store
|
||||
- `RUSTORE_URL` - URL RuStore
|
||||
|
||||
#### Support URLs
|
||||
- `TELEGRAM_SUPPORT_URL` - URL поддержки в Telegram
|
||||
- `SUPPORT_EMAIL` - email для поддержки
|
||||
|
||||
#### Test Credentials (только для разработки)
|
||||
- `TEST_EMAIL` - тестовый email
|
||||
- `TEST_PASSWORD` - тестовый пароль
|
||||
|
||||
#### Email Validation API
|
||||
- `EMAIL_VALIDATION_API_URL` - URL API для валидации email
|
||||
|
||||
#### Connection Check URLs
|
||||
- `GOOGLE_CHECK_URL` - URL для проверки соединения (Google)
|
||||
- `YANDEX_CHECK_URL` - URL для проверки соединения (Yandex)
|
||||
|
||||
#### VK ID Configuration
|
||||
- `VK_CLIENT_ID` - ID приложения VK (числовой)
|
||||
- **Примечание**: VK_CLIENT_ID - это числовой "ID приложения" из консоли VK ID, НЕ "Защищённый ключ" или "Сервисный ключ доступа"
|
||||
|
||||
#### Yandex ID Configuration
|
||||
- `YANDEX_CLIENT_ID` - ID приложения Yandex OAuth
|
||||
- `YANDEX_CLIENT_SECRET` - секретный ключ приложения Yandex OAuth
|
||||
- `YANDEX_REDIRECT_URI` - URI для перенаправления (должен совпадать с настройками в Yandex OAuth приложении)
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Framework
|
||||
- **Flutter**: SDK 3.8.0+
|
||||
- **Dart**: Language version >=3.8.0 <4.0.0
|
||||
- **Version**: 1.3.0+79 (build 79)
|
||||
|
||||
### State Management
|
||||
- **flutter_bloc**: ^9.1.1 - BLoC pattern implementation
|
||||
- **equatable**: ^2.0.7 - Value equality for state objects
|
||||
|
||||
### Data Management
|
||||
- **freezed**: ^3.2.0 - Immutable data classes
|
||||
- **freezed_annotation**: ^3.1.0 - Code generation annotations
|
||||
- **json_annotation**: ^4.9.0 - JSON serialization
|
||||
- **json_serializable**: ^6.10.0 - JSON code generation
|
||||
|
||||
### Networking
|
||||
- **dio**: ^5.9.0 - HTTP client
|
||||
- **retrofit**: ^4.7.1 - Type-safe REST client
|
||||
- **retrofit_generator**: ^10.0.2 - Retrofit code generation
|
||||
- **http**: ^1.5.0 - Additional HTTP support
|
||||
- **http_parser**: ^4.1.2 - HTTP parsing utilities
|
||||
|
||||
### Real-time Communication
|
||||
- **dart_pusher_channels**: ^1.2.3 - WebSocket support via Pusher
|
||||
- **connectivity_plus**: ^6.1.5 - Network connectivity monitoring
|
||||
|
||||
### Navigation & Routing
|
||||
- **go_router**: ^16.1.0 - Declarative routing
|
||||
- Deep linking support for content
|
||||
|
||||
### UI Components
|
||||
- **cached_network_image**: ^3.4.1 - Image caching
|
||||
- **flutter_svg**: ^2.2.0 - SVG rendering
|
||||
- **google_fonts**: ^6.3.0 - Mulish font family
|
||||
- **carousel_slider_plus**: ^7.1.1 - Carousel widgets
|
||||
- **photo_view**: ^0.15.0 - Image viewer
|
||||
- **dismissible_page**: ^1.0.2 - Swipe to dismiss
|
||||
- **badges**: ^3.1.2 - Notification badges
|
||||
- **lottie**: ^3.3.1 - Animation support
|
||||
- **flutter_portal**: ^1.1.4 - Overlay widgets
|
||||
- **dotted_border**: ^3.1.0 - Dotted borders
|
||||
- **flutter_rating_bar**: ^4.0.1 - Rating widgets
|
||||
|
||||
### Forms & Input
|
||||
- **flutter_form_builder**: ^10.2.0 - Form management
|
||||
- **validators**: ^3.0.0 - Input validation
|
||||
- **flutter_typeahead**: ^5.2.0 - Autocomplete
|
||||
- **keyboard_dismisser**: ^3.0.0 - Keyboard management
|
||||
- **flutter_keyboard_visibility**: ^6.0.0 - Keyboard state
|
||||
- **mask_text_input_formatter**: ^2.9.0 - Input masking
|
||||
|
||||
### Media & Files
|
||||
- **image_picker**: ^1.2.0 - Image selection
|
||||
- **flutter_image_compress**: ^2.4.0 - Image compression
|
||||
- **image**: ^4.5.4 - Image manipulation
|
||||
- **file_picker**: ^10.3.1 - File selection
|
||||
- **audioplayers**: ^6.5.0 - Audio playback
|
||||
- **qr_code_scanner_plus**: ^2.0.10+1 - QR code scanning
|
||||
|
||||
### Data Visualization
|
||||
- **syncfusion_flutter_charts**: ^30.2.5 - Chart widgets
|
||||
- **syncfusion_flutter_gauges**: ^30.2.5 - Gauge widgets
|
||||
- **table_calendar**: ^3.2.0 - Calendar widget
|
||||
|
||||
### Storage & Persistence
|
||||
- **shared_preferences**: ^2.5.3 - Local key-value storage
|
||||
- **path_provider**: ^2.1.5 - File system paths
|
||||
- **path**: ^1.9.1 - Path manipulation
|
||||
|
||||
### Firebase
|
||||
- **firebase_core**: ^4.0.0 - Firebase initialization
|
||||
- **firebase_messaging**: ^16.0.0 - Push notifications
|
||||
|
||||
### Analytics & Monitoring
|
||||
- **sentry_flutter**: ^9.6.0 - Error tracking
|
||||
- **appmetrica_plugin**: ^3.3.0 - Analytics (Russian market)
|
||||
|
||||
### Authentication
|
||||
- **vk_id**: ^1.0.3 - VK social login
|
||||
- **flutter_login_yandex**: ^0.0.5 - Yandex ID login
|
||||
- OAuth support for OK and Mail.ru
|
||||
|
||||
### Utilities
|
||||
- **url_launcher**: ^6.3.2 - URL handling
|
||||
- **easy_debounce**: ^2.0.3 - Debouncing
|
||||
- **share_plus**: ^11.1.0 - Content sharing
|
||||
- **flutter_html**: ^3.0.0 - HTML rendering
|
||||
- **webview_flutter**: ^4.13.0 - WebView
|
||||
- **intl**: ^0.20.2 - Internationalization
|
||||
- **collection**: ^1.19.1 - Collection utilities
|
||||
- **uuid**: ^4.5.1 - UUID generation
|
||||
- **visibility_detector**: ^0.4.0+2 - Visibility tracking
|
||||
- **fluttertoast**: ^8.2.12 - Toast messages
|
||||
- **version**: ^3.0.2 - Version management
|
||||
- **package_info_plus**: ^8.3.1 - Package info
|
||||
- **device_info_plus**: ^11.5.0 - Device information
|
||||
- **permission_handler**: ^12.0.1 - Permissions
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Code Generation
|
||||
- Run `flutter pub run build_runner build` for code generation
|
||||
- Run `flutter pub run build_runner build --delete-conflicting-outputs` for clean generation
|
||||
- Use `flutter pub run build_runner watch` for watch mode
|
||||
|
||||
### Environment Setup
|
||||
- Configure `.env` file with all required variables
|
||||
- Set up Firebase for both Android and iOS platforms
|
||||
- Configure social authentication providers (VK, Yandex, etc.)
|
||||
|
||||
### Testing
|
||||
- Unit tests with `flutter_test`
|
||||
- Integration tests with `integration_test`
|
||||
- Mocking with `mockito`
|
||||
|
||||
## Build Commands
|
||||
|
||||
### Development Builds
|
||||
```bash
|
||||
# Run on connected device
|
||||
flutter run
|
||||
|
||||
# Run on specific Android device
|
||||
flutter run -d emulator-5554
|
||||
|
||||
# Run on specific iOS simulator
|
||||
flutter run -d iPhone\ 15
|
||||
```
|
||||
|
||||
### Production Builds
|
||||
|
||||
#### Android
|
||||
```bash
|
||||
# Build APK (split by ABI)
|
||||
flutter build apk --release --split-per-abi
|
||||
|
||||
# Build App Bundle
|
||||
flutter build appbundle --release
|
||||
|
||||
# Huawei-specific build (with HMS)
|
||||
flutter build apk --release --flavor production --target-platform android-arm64
|
||||
```
|
||||
|
||||
#### iOS
|
||||
```bash
|
||||
# Build IPA for TestFlight
|
||||
flutter build ipa --export-options-plist=ExportOptions.plist
|
||||
|
||||
# Build for specific device architecture
|
||||
flutter build ipa --export-options-plist=ExportOptions.plist --no-codesign
|
||||
```
|
||||
|
||||
## Optimization Techniques
|
||||
|
||||
### Android Build Optimization
|
||||
1. **ProGuard Rules**:
|
||||
- Configure rules in [`android/app/proguard-rules.pro`](android/app/proguard-rules.pro:1)
|
||||
- Essential rules for Flutter:
|
||||
```
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
```
|
||||
|
||||
2. **Resource Shrinking**:
|
||||
- Enabled in `android/app/build.gradle`:
|
||||
```gradle
|
||||
buildTypes {
|
||||
release {
|
||||
shrinkResources true
|
||||
minifyEnabled true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Code Obfuscation**:
|
||||
- Use `--obfuscate` flag with build commands
|
||||
- Verify with `flutter build apk --release --obfuscate --split-debug-info=debug-info/`
|
||||
|
||||
4. **Image Compression Workflow**:
|
||||
- Automatic compression via [`flutter_image_compress`](lib/features/create_diet/models/create_diet_model.dart:1)
|
||||
- Configuration in image upload flows:
|
||||
```dart
|
||||
final compressedFile = await FlutterImageCompress.compressWithFile(
|
||||
file.path,
|
||||
quality: 85,
|
||||
minWidth: 1024,
|
||||
minHeight: 1024,
|
||||
);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Missing .env Variables
|
||||
- **Symptom**: Application crashes on startup with "Missing environment variable" error
|
||||
- **Solution**:
|
||||
1. Verify `.env` file exists in project root
|
||||
2. Check [`ENV_SETUP.md`](ENV_SETUP.md) for required variables
|
||||
3. Regenerate configuration with `flutter clean && flutter pub get`
|
||||
|
||||
#### Firebase Configuration Errors
|
||||
- **Android**:
|
||||
- Missing `google-services.json` in `android/app/`
|
||||
- Missing `agconnect-services.json` for Huawei devices
|
||||
- Verify SHA-1 fingerprints match Firebase console
|
||||
- **iOS**:
|
||||
- Missing `GoogleService-Info.plist` in `ios/Runner`
|
||||
- Check bundle identifier matches Firebase project
|
||||
|
||||
#### WebSocket Connection Failures
|
||||
- **Symptoms**: Chat messages not delivering, real-time updates not working
|
||||
- **Solutions**:
|
||||
1. Verify Pusher configuration in [`lib/core/websockets/ws_manager.dart`](lib/core/websockets/ws_manager.dart:1)
|
||||
2. Check network connectivity with `connectivity_plus` package
|
||||
3. Ensure proper authentication token is sent with WebSocket connection
|
||||
|
||||
#### Platform-Specific Build Errors
|
||||
- **Huawei vs Google Services**:
|
||||
- For Huawei devices, ensure `agconnect-services.json` is present
|
||||
- Use `--flavor` flag to specify build variant:
|
||||
```bash
|
||||
flutter build apk --release --flavor hms
|
||||
```
|
||||
- Check for conflicting dependencies between HMS and GMS
|
||||
|
||||
- **iOS Code Signing Issues**:
|
||||
- Verify `ExportOptions.plist` contains correct provisioning profiles
|
||||
- Update signing certificates in Xcode
|
||||
- Clean build folder: `flutter clean && rm -rf ios/Pods ios/Podfile.lock`
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Content View Tracking
|
||||
- `ViewsCubit` handles visibility detection
|
||||
- `VisibilityDetector` widget tracks on-screen time
|
||||
- Views sent to backend when 50%+ visible for 2+ seconds
|
||||
- Debounced to prevent excessive API calls
|
||||
|
||||
### Image Handling
|
||||
- `ImagePicker` for selection
|
||||
- `FlutterImageCompress` for compression
|
||||
- MultiPart form data upload via Dio
|
||||
- Preview via `CachedNetworkImage`
|
||||
|
||||
### Search Implementation
|
||||
- Debounced search with 500ms delay
|
||||
- Min query length: 3 characters
|
||||
- Applied to: Feed, Recipes, Diets, Plans, Consultants
|
||||
- Tag-based filtering for recipes
|
||||
|
||||
### Pagination Pattern
|
||||
- Page-based pagination
|
||||
- Scroll detection for next page load
|
||||
- Loading state during fetch
|
||||
- Meta data tracking (current page, last page)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication
|
||||
- Bearer token authentication
|
||||
- Secure token storage
|
||||
- Automatic session management
|
||||
- OAuth 2.0 for social login
|
||||
|
||||
### Data Protection
|
||||
- HTTPS only communication
|
||||
- Token encryption in storage
|
||||
- Secure WebSocket connections
|
||||
- Privacy policy compliance
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Entry Point
|
||||
- `lib/main.dart` - App initialization
|
||||
|
||||
### Core Infrastructure
|
||||
- `lib/core/app_initializer.dart` - Startup sequence
|
||||
- `lib/core/locator_service.dart` - DI container
|
||||
- `lib/core/app_config.dart` - App configuration
|
||||
- `lib/core/app_router.dart` - Routing setup
|
||||
|
||||
### WebSocket
|
||||
- `lib/core/websockets/ws_manager.dart` - Connection management
|
||||
- `lib/core/websockets/ws_service.dart` - API service
|
||||
|
||||
### Authentication
|
||||
- `lib/features/auth/sdk/social_auth_facade.dart` - Social auth
|
||||
- `lib/core/token_storage.dart` - Token persistence
|
||||
- `lib/core/interceptors/token_interceptor.dart` - Auth interceptor
|
||||
|
||||
### Environment
|
||||
- `lib/core/env_config.dart` - Environment variables
|
||||
- `.env` - Environment file (not in repo)
|
||||
|
||||
## Key Success Metrics
|
||||
|
||||
- User engagement with food diary
|
||||
- Consultation bookings and completion rates
|
||||
- Content creation by consultants
|
||||
- User retention and progress tracking
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
- **Last Updated**: 2025-10-16
|
||||
- All paths are relative to `/home/apoloz/dev/eatvio_app`
|
||||
- Russian market focus maintained throughout documentation
|
||||
- Version compatibility matrix:
|
||||
| Component | Version | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Flutter | 3.8.0+ | Required |
|
||||
| Dart | 3.8.0-3.9.9 | Required |
|
||||
| Android SDK | 34 | Target SDK |
|
||||
| iOS Deployment | 14.0 | Minimum |
|
||||
|
||||
## Ubuntu Development PC Optimization
|
||||
```bash
|
||||
-# apt install linux-oem-24.04
|
||||
-# sudo apt-get install --install-recommends linux-lowlatency-hwe-24.04
|
||||
```
|
||||
----
|
||||
- ubuntu switch to hwe [kernel](https://canonical-kernel-docs.readthedocs-hosted.com/latest/reference/hwe-kernels/#installing-a-hwe-kernel)
|
||||
----
|
||||
- ubuntu instal intel iRIS gpu [drivers](https://dgpu-docs.intel.com/driver/client/overview.html#ubuntu-latest)
|
||||
```
|
||||
|
||||
@@ -63,6 +63,18 @@
|
||||
|
||||
<data android:pathPattern="/ru/plan/.*" />
|
||||
<data android:pathPattern="/en/plan/.*" />
|
||||
|
||||
<data android:pathPattern="/ru/chat/.*" />
|
||||
<data android:pathPattern="/en/chat/.*" />
|
||||
|
||||
<data android:pathPattern="/ru/chat" />
|
||||
<data android:pathPattern="/en/chat" />
|
||||
|
||||
<data android:pathPattern="/ru/profile/.*" />
|
||||
<data android:pathPattern="/en/profile/.*" />
|
||||
|
||||
<data android:pathPattern="/ru/diary/.*" />
|
||||
<data android:pathPattern="/en/diary/.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
|
||||
9
assets/icons/logo_2.svg
Normal file
9
assets/icons/logo_2.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="89" height="90" viewBox="0 0 89 90" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.0119 0C65.9947 0 83 9.05026 83 29.6764C83 50.2792 65.9947 67 45.0119 67C24.0053 67 7 50.2792 7 29.6764C7 9.05026 24.0053 0 45.0119 0Z" fill="#43A6F2"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.8844 43.5162C39.8585 43.5162 34.9274 42.5623 30.3282 40.7209C25.7291 38.8573 21.5803 36.1284 18.1428 32.7119C16.5544 31.1589 16.6493 28.6741 18.3087 27.2098C19.9919 25.7234 22.6234 25.8122 24.2118 27.3651C26.867 30.0052 30.0675 32.1129 33.6235 33.5327C37.1796 34.9748 40.9964 35.7069 44.8844 35.7069C48.7486 35.7069 52.5655 34.9748 56.1215 33.5327C59.6776 32.1129 62.878 30.0052 65.5332 27.3651C67.1216 25.8122 69.7531 25.7234 71.4363 27.2098C73.0958 28.6741 73.1906 31.1589 71.6022 32.7119C68.1647 36.1284 64.016 38.8573 59.4168 40.7209C54.8176 42.5623 49.8866 43.5162 44.8844 43.5162Z" fill="white"/>
|
||||
<path d="M9.69721 80.8957L3.51318 88.877C2.93793 89.6257 2.28049 90 1.54087 90C0.513623 90 0 89.3382 0 88.0147V77.3663C0 75.7888 0.56841 75 1.70523 75C2.84205 75 3.41046 75.7888 3.41046 77.3663V84.2246L9.71775 76.0829C10.2793 75.361 10.882 75 11.5257 75C12.5803 75 13.1077 75.7487 13.1077 77.246V87.6337C13.1077 89.2112 12.5393 90 11.4024 90C10.2656 90 9.69721 89.2112 9.69721 87.6337V80.8957Z" fill="#323F4B"/>
|
||||
<path d="M23.0017 78.2888C21.769 78.2888 21.0019 78.2553 20.7006 78.1885C19.7829 78.0013 19.3241 77.5201 19.3241 76.7447C19.3241 75.7019 20.1322 75.1805 21.7484 75.1805H27.6859C29.3021 75.1805 30.1102 75.7019 30.1102 76.7447C30.1102 77.5201 29.6514 78.0013 28.7337 78.1885C28.4324 78.2553 27.6654 78.2888 26.4327 78.2888V87.6337C26.4327 89.2112 25.8642 90 24.7274 90C23.5769 90 23.0017 89.2112 23.0017 87.6337V78.2888Z" fill="#323F4B"/>
|
||||
<path d="M38.6564 89.8195C37.7935 89.8195 37.1909 89.6791 36.8485 89.3984C36.506 89.1043 36.3348 88.5428 36.3348 87.7139V77.5267C36.3348 76.6578 36.506 76.0495 36.8485 75.7019C37.1909 75.3543 37.8072 75.1805 38.6975 75.1805H41.4916C42.8476 75.1805 43.9981 75.4545 44.9432 76.0027C46.0937 76.6578 46.6689 77.6003 46.6689 78.8302C46.6689 80.4211 46.08 81.4973 44.9021 82.0588V82.119C45.7787 82.3864 46.4087 82.7807 46.7922 83.3021C47.1757 83.8102 47.3675 84.512 47.3675 85.4078C47.3675 86.8917 46.8401 88.0214 45.7855 88.7968C44.8541 89.4786 43.6009 89.8195 42.0258 89.8195H38.6564ZM39.7453 83.5027V86.9519H41.4505C42.1764 86.9519 42.7175 86.8583 43.0736 86.6711C43.5393 86.4171 43.7721 85.9626 43.7721 85.3075C43.7721 84.5455 43.5461 84.0374 43.0941 83.7834C42.7654 83.5963 42.197 83.5027 41.3889 83.5027H39.7453ZM39.7453 78.0281V80.8957H41.2245C41.8957 80.8957 42.3682 80.8155 42.6421 80.6551C43.0256 80.4412 43.2174 80.0401 43.2174 79.4519C43.2174 78.5027 42.5531 78.0281 41.2245 78.0281H39.7453Z" fill="#323F4B"/>
|
||||
<path d="M64.1493 80.8957L57.9653 88.877C57.39 89.6257 56.7326 90 55.993 90C54.9657 90 54.4521 89.3382 54.4521 88.0147V77.3663C54.4521 75.7888 55.0205 75 56.1573 75C57.2941 75 57.8626 75.7888 57.8626 77.3663V84.2246L64.1698 76.0829C64.7314 75.361 65.3341 75 65.9778 75C67.0324 75 67.5598 75.7487 67.5598 77.246V87.6337C67.5598 89.2112 66.9913 90 65.8545 90C64.7177 90 64.1493 89.2112 64.1493 87.6337V80.8957Z" fill="#323F4B"/>
|
||||
<path d="M74.6391 82.5201C74.6391 80.3275 75.2691 78.5428 76.5292 77.1658C77.8304 75.7219 79.5904 75 81.8093 75C84.0418 75 85.8087 75.7152 87.1099 77.1457C88.37 78.5227 89 80.3142 89 82.5201C89 84.7126 88.37 86.4973 87.1099 87.8743C85.795 89.2914 84.0281 90 81.8093 90C79.5904 90 77.8304 89.2848 76.5292 87.8543C75.2691 86.4906 74.6391 84.7126 74.6391 82.5201ZM78.255 82.5602C78.255 83.7634 78.5358 84.7594 79.0973 85.5481C79.7274 86.4706 80.6314 86.9318 81.8093 86.9318C82.9872 86.9318 83.898 86.4773 84.5417 85.5682C85.1033 84.766 85.3841 83.7634 85.3841 82.5602C85.3841 81.3168 85.117 80.2941 84.5828 79.492C83.9391 78.5428 83.0146 78.0682 81.8093 78.0682C80.604 78.0682 79.6863 78.5428 79.0563 79.492C78.5221 80.2941 78.255 81.3168 78.255 82.5602Z" fill="#323F4B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/images/logo_bg.png
Normal file
BIN
assets/images/logo_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
28716
coverage/lcov.info
Normal file
28716
coverage/lcov.info
Normal file
File diff suppressed because it is too large
Load Diff
39
integration_test/auth_screen/auth_screen_login_test.dart
Normal file
39
integration_test/auth_screen/auth_screen_login_test.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
||||
import 'package:eatvio_app/main.dart' as app;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import '../common_cases/skip_welcome_screen.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await AppInitializer.initialize(buildType: AppBuildType.tests);
|
||||
});
|
||||
|
||||
testWidgets('Signs in successfully with valid credentials', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
await skipWelcomeScreen(tester);
|
||||
final emailFieldFinder = find.byKey(AppKeys.emailTextFieldAuthScreen);
|
||||
final passwordFieldFinder = find.byKey(AppKeys.passwordTextFieldAuthScreen);
|
||||
final signInButtonFinder = find.byKey(AppKeys.signInButtonAuthScreen);
|
||||
|
||||
expect(emailFieldFinder, findsOneWidget, reason: 'Email text field should be found.');
|
||||
expect(passwordFieldFinder, findsOneWidget, reason: 'Password text field should be found.');
|
||||
expect(signInButtonFinder, findsOneWidget, reason: 'Sign In button should be found.');
|
||||
|
||||
await tester.enterText(emailFieldFinder, EnvConfig.testEmail);
|
||||
await tester.enterText(passwordFieldFinder, EnvConfig.testPassword);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(signInButtonFinder);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
|
||||
expect(FeedScreen, findsOne, reason: 'Feed screen should be visible after successful login and navigation.');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:eatvio_app/main.dart' as app;
|
||||
import '../common_cases/skip_welcome_screen.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await AppInitializer.initialize(buildType: AppBuildType.tests);
|
||||
});
|
||||
|
||||
testWidgets('tapping logo 7 times opens dev tools', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
await skipWelcomeScreen(tester);
|
||||
final logoButtonFinder = find.byKey(AppKeys.hiddenLogoButtonAuthScreen);
|
||||
expect(logoButtonFinder, findsOneWidget, reason: 'The logo GestureDetector should be found in auth screen');
|
||||
|
||||
for (int i = 0; i < 7; i++) {
|
||||
await tester.tap(logoButtonFinder);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DevToolsScreen), findsOneWidget, reason: 'DevToolsScreen should be visible after tapping the logo 7 times.');
|
||||
});
|
||||
}
|
||||
21
integration_test/common_cases/skip_sign_in.dart
Normal file
21
integration_test/common_cases/skip_sign_in.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> skipSignIn(WidgetTester tester) async {
|
||||
final emailFieldFinder = find.byKey(AppKeys.emailTextFieldAuthScreen);
|
||||
final passwordFieldFinder = find.byKey(AppKeys.passwordTextFieldAuthScreen);
|
||||
final signInButtonFinder = find.byKey(AppKeys.signInButtonAuthScreen);
|
||||
|
||||
expect(emailFieldFinder, findsOneWidget, reason: 'Email text field should be found.');
|
||||
expect(passwordFieldFinder, findsOneWidget, reason: 'Password text field should be found.');
|
||||
expect(signInButtonFinder, findsOneWidget, reason: 'Sign In button should be found.');
|
||||
|
||||
await tester.enterText(emailFieldFinder, EnvConfig.testEmail);
|
||||
await tester.enterText(passwordFieldFinder, EnvConfig.testPassword);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(signInButtonFinder);
|
||||
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
}
|
||||
8
integration_test/common_cases/skip_welcome_screen.dart
Normal file
8
integration_test/common_cases/skip_welcome_screen.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Future<void> skipWelcomeScreen(WidgetTester tester) async {
|
||||
final welcomeScreenButtonFinder = find.byKey(AppKeys.welcomeScreenButton);
|
||||
await tester.tap(welcomeScreenButtonFinder);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
147
integration_test/create_recipe/README.md
Normal file
147
integration_test/create_recipe/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Интеграционные тесты создания рецепта
|
||||
|
||||
Этот набор интеграционных тестов покрывает полный пользовательский путь создания нового рецепта в приложении Eatvio.
|
||||
|
||||
## Структура файлов
|
||||
|
||||
- `create_recipe_test.dart` - Основные интеграционные тесты
|
||||
- `create_recipe_helpers.dart` - Helper функции для тестирования
|
||||
- `README.md` - Документация
|
||||
|
||||
## Тестовые сценарии
|
||||
|
||||
### 1. Полное создание рецепта
|
||||
**Файл:** `create_recipe_test.dart` - `should create and publish a new recipe successfully`
|
||||
|
||||
**Описание:** Тестирует полный пользовательский путь создания рецепта с заполнением всех полей.
|
||||
|
||||
**Шаги:**
|
||||
1. Запуск приложения и вход в систему
|
||||
2. Навигация к экрану создания рецепта
|
||||
3. Заполнение основной информации (название, тип, порции, калории, время)
|
||||
4. Добавление тегов
|
||||
5. Добавление ингредиентов
|
||||
6. Добавление шагов приготовления
|
||||
7. Симуляция добавления изображения
|
||||
8. Публикация рецепта
|
||||
9. Проверка успешного перехода
|
||||
|
||||
### 2. Валидация полей
|
||||
**Файл:** `create_recipe_test.dart` - `should validate required fields before publishing`
|
||||
|
||||
**Описание:** Проверяет, что валидация обязательных полей работает корректно.
|
||||
|
||||
**Шаги:**
|
||||
1. Запуск приложения и вход в систему
|
||||
2. Навигация к экрану создания рецепта
|
||||
3. Попытка публикации без заполнения полей
|
||||
4. Проверка, что остались на экране создания (валидация не прошла)
|
||||
|
||||
### 3. Минимальное создание рецепта
|
||||
**Файл:** `create_recipe_test.dart` - `should create recipe with minimal required fields`
|
||||
|
||||
**Описание:** Тестирует создание рецепта с минимально необходимыми полями.
|
||||
|
||||
**Шаги:**
|
||||
1. Запуск приложения и вход в систему
|
||||
2. Навигация к экрану создания рецепта
|
||||
3. Заполнение только обязательных полей
|
||||
4. Добавление минимума ингредиентов
|
||||
5. Симуляция добавления изображения
|
||||
6. Публикация рецепта
|
||||
7. Проверка успешного перехода
|
||||
|
||||
## Helper функции
|
||||
|
||||
### Навигация
|
||||
- `navigateToCreateRecipeScreen()` - Переход к экрану создания рецепта
|
||||
|
||||
### Заполнение формы
|
||||
- `fillRecipeBasicInfo()` - Заполнение основной информации о рецепте
|
||||
- `selectRecipeType()` - Выбор типа рецепта
|
||||
- `addTags()` - Добавление тегов
|
||||
- `addIngredients()` - Добавление ингредиентов
|
||||
- `addCookingSteps()` - Добавление шагов приготовления
|
||||
|
||||
### Публикация и проверки
|
||||
- `publishRecipe()` - Публикация рецепта
|
||||
- `verifyRecipePublished()` - Проверка успешной публикации
|
||||
- `verifyValidationErrors()` - Проверка ошибок валидации
|
||||
|
||||
### Симуляция изображений
|
||||
- `mockImageSelection()` - Симуляция выбора изображения для рецепта
|
||||
- `mockStepImageSelection()` - Симуляция выбора изображения для шага
|
||||
|
||||
### Тестовые данные
|
||||
- `createTestRecipeData()` - Создание набора тестовых данных
|
||||
|
||||
## Ключи для тестирования
|
||||
|
||||
В файле `lib/app_keys.dart` добавлены следующие ключи для стабильного тестирования:
|
||||
|
||||
### Основные поля
|
||||
- `recipeCreateScreenTitleField` - Поле названия
|
||||
- `recipeCreateScreenDescriptionField` - Поле описания
|
||||
- `recipeCreateScreenTypeDropdown` - Выпадающий список типа рецепта
|
||||
- `recipeCreateScreenPortionsField` - Поле количества порций
|
||||
- `recipeCreateScreenCaloriesField` - Поле калорийности
|
||||
|
||||
### Время приготовления
|
||||
- `recipeCreateScreenTotalHoursField` - Часы общего времени
|
||||
- `recipeCreateScreenTotalMinutesField` - Минуты общего времени
|
||||
- `recipeCreateScreenActiveHoursField` - Часы активного времени
|
||||
- `recipeCreateScreenActiveMinutesField` - Минуты активного времени
|
||||
|
||||
### Теги и ингредиенты
|
||||
- `recipeCreateScreenTagField` - Поле для ввода тега
|
||||
- `recipeCreateScreenAddTagButton` - Кнопка добавления тега
|
||||
- `recipeCreateScreenAddIngredientButton` - Кнопка добавления ингредиента
|
||||
|
||||
### Шаги приготовления
|
||||
- `recipeCreateScreenAddStepButton` - Кнопка добавления шага
|
||||
- `recipeCreateScreenStepDescriptionField` - Поле описания шага
|
||||
- `recipeCreateScreenStepImageWidget` - Виджет изображения шага
|
||||
|
||||
### Изображения и публикация
|
||||
- `recipeCreateScreenImageWidget` - Виджет основного изображения
|
||||
- `recipeCreateScreenPublishButton` - Кнопка публикации
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
```bash
|
||||
# Запуск всех интеграционных тестов
|
||||
flutter test integration_test/
|
||||
|
||||
# Запуск только тестов создания рецепта
|
||||
flutter test integration_test/create_recipe/
|
||||
|
||||
# Запуск конкретного теста
|
||||
flutter test integration_test/create_recipe/create_recipe_test.dart
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Flutter SDK
|
||||
- Настроенная тестовая среда (AppBuildType.tests)
|
||||
- Тестовые учетные данные в .env файле
|
||||
- Доступ к API бэкенда для тестирования
|
||||
|
||||
## Примечания
|
||||
|
||||
1. **Изображения**: В текущей реализации используется симуляция выбора изображений. Для полноценного тестирования рекомендуется добавить моки для `image_picker`.
|
||||
|
||||
2. **API взаимодействие**: Тесты взаимодействуют с реальным API бэкенда. Убедитесь, что тестовая среда настроена корректно.
|
||||
|
||||
3. **Время выполнения**: Тесты могут занимать значительное время из-за анимаций и API запросов. Используйте `pumpAndSettle()` с достаточными таймаутами.
|
||||
|
||||
4. **Стабильность**: Ключи для тестирования добавлены в UI компоненты для обеспечения стабильности тестов при изменениях в интерфейсе.
|
||||
|
||||
## Расширение тестов
|
||||
|
||||
Для добавления новых тестовых сценариев:
|
||||
|
||||
1. Создайте новый `testWidgets` в `create_recipe_test.dart`
|
||||
2. Используйте существующие helper функции или создайте новые в `create_recipe_helpers.dart`
|
||||
3. При необходимости добавьте новые ключи в `app_keys.dart`
|
||||
4. Обновите документацию в этом файле
|
||||
|
||||
237
integration_test/create_recipe/create_recipe_helpers.dart
Normal file
237
integration_test/create_recipe/create_recipe_helpers.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Helper функции для интеграционных тестов создания рецепта
|
||||
|
||||
/// Навигация к экрану создания рецепта
|
||||
Future<void> navigateToCreateRecipeScreen(WidgetTester tester) async {
|
||||
// Переключаемся на вкладку "Рецепты" если она есть
|
||||
final recipesTab = find.text('Рецепты');
|
||||
if (recipesTab.evaluate().isNotEmpty) {
|
||||
await tester.tap(recipesTab);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Находим кнопку добавления рецепта (круглая кнопка с плюсом)
|
||||
final addRecipeButton = find.byType(InkWell).first;
|
||||
await tester.tap(addRecipeButton);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
/// Заполняет основную информацию о рецепте
|
||||
Future<void> fillRecipeBasicInfo(
|
||||
WidgetTester tester, {
|
||||
String title = 'Интеграционный Тестовый Рецепт',
|
||||
TypeOfRecipe type = TypeOfRecipe.salads,
|
||||
int portions = 4,
|
||||
double calories = 250.0,
|
||||
int totalHours = 1,
|
||||
int totalMinutes = 30,
|
||||
int activeHours = 0,
|
||||
int activeMinutes = 45,
|
||||
String description = 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.',
|
||||
}) async {
|
||||
// Заполняем название рецепта
|
||||
final titleField = find.byKey(AppKeys.recipeCreateScreenTitleField);
|
||||
await tester.enterText(titleField, title);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Выбираем тип рецепта
|
||||
await selectRecipeType(tester, type);
|
||||
|
||||
// Заполняем количество порций
|
||||
final portionsField = find.byKey(AppKeys.recipeCreateScreenPortionsField);
|
||||
await tester.enterText(portionsField, portions.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Заполняем калорийность
|
||||
final caloriesField = find.byKey(AppKeys.recipeCreateScreenCaloriesField);
|
||||
await tester.enterText(caloriesField, calories.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Заполняем общее время приготовления
|
||||
final totalHoursField = find.byKey(AppKeys.recipeCreateScreenTotalHoursField);
|
||||
await tester.enterText(totalHoursField, totalHours.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final totalMinutesField = find.byKey(AppKeys.recipeCreateScreenTotalMinutesField);
|
||||
await tester.enterText(totalMinutesField, totalMinutes.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Заполняем активное время приготовления
|
||||
final activeHoursField = find.byKey(AppKeys.recipeCreateScreenActiveHoursField);
|
||||
await tester.enterText(activeHoursField, activeHours.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final activeMinutesField = find.byKey(AppKeys.recipeCreateScreenActiveMinutesField);
|
||||
await tester.enterText(activeMinutesField, activeMinutes.toString());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Заполняем описание
|
||||
final descriptionField = find.byKey(AppKeys.recipeCreateScreenDescriptionField);
|
||||
await tester.enterText(descriptionField, description);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Выбирает тип рецепта из выпадающего списка
|
||||
Future<void> selectRecipeType(WidgetTester tester, TypeOfRecipe type) async {
|
||||
final typeDropdown = find.byKey(AppKeys.recipeCreateScreenTypeDropdown);
|
||||
await tester.tap(typeDropdown);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Выбираем нужный тип из выпадающего списка
|
||||
final typeOption = find.text(type.toString());
|
||||
await tester.tap(typeOption);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Добавляет теги к рецепту
|
||||
Future<void> addTags(WidgetTester tester, List<String> tags) async {
|
||||
final tagField = find.byKey(AppKeys.recipeCreateScreenTagField);
|
||||
final addTagButton = find.byKey(AppKeys.recipeCreateScreenAddTagButton);
|
||||
|
||||
for (final tag in tags) {
|
||||
await tester.enterText(tagField, tag);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(addTagButton);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет ингредиенты к рецепту
|
||||
Future<void> addIngredients(WidgetTester tester, List<Map<String, String>> ingredients) async {
|
||||
final addIngredientButton = find.byKey(AppKeys.recipeCreateScreenAddIngredientButton);
|
||||
|
||||
for (final ingredient in ingredients) {
|
||||
await tester.ensureVisible(addIngredientButton);
|
||||
await tester.tap(addIngredientButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await addIngredientInModal(tester, ingredient['name']!, ingredient['quantity']!);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет ингредиент в модальном окне
|
||||
Future<void> addIngredientInModal(WidgetTester tester, String name, String quantity) async {
|
||||
// Ищем поля в модальном окне
|
||||
final nameFields = find.byType(TextFormField);
|
||||
if (nameFields.evaluate().isNotEmpty) {
|
||||
await tester.enterText(nameFields.first, name);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Ищем поле количества (второе поле)
|
||||
if (nameFields.evaluate().length > 1) {
|
||||
await tester.enterText(nameFields.at(1), quantity);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Ищем кнопку добавления в модальном окне
|
||||
final addButton = find.text('Добавить');
|
||||
if (addButton.evaluate().isNotEmpty) {
|
||||
await tester.tap(addButton);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет шаги приготовления
|
||||
Future<void> addCookingSteps(WidgetTester tester, List<Map<String, String>> steps) async {
|
||||
final addStepButton = find.byKey(AppKeys.recipeCreateScreenAddStepButton);
|
||||
|
||||
for (int i = 0; i < steps.length; i++) {
|
||||
final step = steps[i];
|
||||
|
||||
// Заполняем описание шага
|
||||
final stepDescription = find.byKey(Key('${AppKeys.recipeCreateScreenStepDescriptionField}_$i'));
|
||||
await tester.enterText(stepDescription, step['description']!);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Симулируем добавление изображения к шагу если указано
|
||||
if (step.containsKey('hasImage') && step['hasImage'] == 'true') {
|
||||
await mockStepImageSelection(tester, i);
|
||||
}
|
||||
|
||||
// Добавляем следующий шаг (кроме последнего)
|
||||
if (i < steps.length - 1) {
|
||||
await tester.ensureVisible(addStepButton);
|
||||
await tester.tap(addStepButton);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Публикует рецепт
|
||||
Future<void> publishRecipe(WidgetTester tester) async {
|
||||
final publishButton = find.byKey(AppKeys.recipeCreateScreenPublishButton);
|
||||
await tester.ensureVisible(publishButton);
|
||||
await tester.tap(publishButton);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 5)); // Дать время на API-запрос
|
||||
}
|
||||
|
||||
/// Симулирует выбор изображения для основного рецепта
|
||||
Future<void> mockImageSelection(WidgetTester tester) async {
|
||||
final imageWidget = find.byKey(AppKeys.recipeCreateScreenImageWidget);
|
||||
if (imageWidget.evaluate().isNotEmpty) {
|
||||
await tester.tap(imageWidget);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// В реальном интеграционном тесте здесь будет мок image_picker
|
||||
// который возвращает тестовое изображение
|
||||
}
|
||||
}
|
||||
|
||||
/// Симулирует выбор изображения для шага приготовления
|
||||
Future<void> mockStepImageSelection(WidgetTester tester, int stepIndex) async {
|
||||
final stepImageWidget = find.byKey(Key('${AppKeys.recipeCreateScreenStepImageWidget}_$stepIndex'));
|
||||
if (stepImageWidget.evaluate().isNotEmpty) {
|
||||
await tester.tap(stepImageWidget);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// В реальном интеграционном тесте здесь будет мок image_picker
|
||||
// который возвращает тестовое изображение
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, что рецепт был успешно опубликован
|
||||
void verifyRecipePublished(WidgetTester tester) {
|
||||
// После успешной публикации должен произойти переход обратно на экран ленты
|
||||
expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsNothing);
|
||||
// Можно добавить дополнительные проверки в зависимости от того, куда происходит переход
|
||||
}
|
||||
|
||||
/// Проверяет, что валидация полей работает корректно
|
||||
void verifyValidationErrors(WidgetTester tester) {
|
||||
// Проверяем, что остались на экране создания рецепта (валидация не прошла)
|
||||
expect(find.byType(RecipeCreateScreen), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsOneWidget);
|
||||
}
|
||||
|
||||
/// Создает тестовые данные для рецепта
|
||||
Map<String, dynamic> createTestRecipeData() {
|
||||
return {
|
||||
'title': 'Интеграционный Тестовый Рецепт',
|
||||
'type': TypeOfRecipe.salads,
|
||||
'portions': 4,
|
||||
'calories': 250.0,
|
||||
'totalHours': 1,
|
||||
'totalMinutes': 30,
|
||||
'activeHours': 0,
|
||||
'activeMinutes': 45,
|
||||
'description': 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.',
|
||||
'tags': ['здоровое питание', 'быстро', 'легко'],
|
||||
'ingredients': [
|
||||
{'name': 'Помидоры', 'quantity': '200'},
|
||||
{'name': 'Огурцы', 'quantity': '150'},
|
||||
{'name': 'Лук', 'quantity': '50'},
|
||||
],
|
||||
'steps': [
|
||||
{'description': 'Помойте и нарежьте помидоры и огурцы кубиками среднего размера.', 'hasImage': 'true'},
|
||||
{'description': 'Нарежьте лук полукольцами и добавьте к овощам.', 'hasImage': 'false'},
|
||||
{'description': 'Смешайте все ингредиенты в большой миске, добавьте соль и перец по вкусу.', 'hasImage': 'true'},
|
||||
],
|
||||
};
|
||||
}
|
||||
128
integration_test/create_recipe/create_recipe_test.dart
Normal file
128
integration_test/create_recipe/create_recipe_test.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/main.dart' as app;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../common_cases/skip_welcome_screen.dart';
|
||||
import '../common_cases/skip_sign_in.dart';
|
||||
import 'create_recipe_helpers.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await AppInitializer.initialize(buildType: AppBuildType.tests);
|
||||
});
|
||||
|
||||
group('Create Recipe Integration Tests', () {
|
||||
testWidgets('should create and publish a new recipe successfully', (tester) async {
|
||||
// 1. Запустить приложение
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 20));
|
||||
|
||||
// 2. Пропустить онбординг и войти в систему
|
||||
await skipWelcomeScreen(tester);
|
||||
await skipSignIn(tester);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
|
||||
// 3. Перейти на экран создания рецепта
|
||||
await navigateToCreateRecipeScreen(tester);
|
||||
|
||||
// Проверяем, что мы на экране создания рецепта
|
||||
expect(find.byType(RecipeCreateScreen), findsOneWidget);
|
||||
|
||||
// 4. Заполнить все поля формы
|
||||
final testData = createTestRecipeData();
|
||||
await fillRecipeBasicInfo(
|
||||
tester,
|
||||
title: testData['title'] as String,
|
||||
type: testData['type'] as TypeOfRecipe,
|
||||
portions: testData['portions'] as int,
|
||||
calories: testData['calories'] as double,
|
||||
totalHours: testData['totalHours'] as int,
|
||||
totalMinutes: testData['totalMinutes'] as int,
|
||||
activeHours: testData['activeHours'] as int,
|
||||
activeMinutes: testData['activeMinutes'] as int,
|
||||
description: testData['description'] as String,
|
||||
);
|
||||
|
||||
// 5. Добавить теги
|
||||
await addTags(tester, testData['tags'] as List<String>);
|
||||
|
||||
// 6. Добавить ингредиенты
|
||||
await addIngredients(tester, testData['ingredients'] as List<Map<String, String>>);
|
||||
|
||||
// 7. Добавить шаги приготовления
|
||||
await addCookingSteps(tester, testData['steps'] as List<Map<String, String>>);
|
||||
|
||||
// 8. Симулировать добавление изображения
|
||||
await mockImageSelection(tester);
|
||||
|
||||
// 9. Нажать "Опубликовать"
|
||||
await publishRecipe(tester);
|
||||
|
||||
// 10. Проверить результат
|
||||
verifyRecipePublished(tester);
|
||||
});
|
||||
|
||||
testWidgets('should validate required fields before publishing', (tester) async {
|
||||
// 1. Запустить приложение и войти в систему
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
await skipWelcomeScreen(tester);
|
||||
await skipSignIn(tester);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
|
||||
// 2. Перейти на экран создания рецепта
|
||||
await navigateToCreateRecipeScreen(tester);
|
||||
|
||||
// 3. Попытаться опубликовать без заполнения полей
|
||||
await publishRecipe(tester);
|
||||
|
||||
// 4. Проверить, что валидация сработала
|
||||
verifyValidationErrors(tester);
|
||||
});
|
||||
|
||||
testWidgets('should create recipe with minimal required fields', (tester) async {
|
||||
// 1. Запустить приложение и войти в систему
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
await skipWelcomeScreen(tester);
|
||||
await skipSignIn(tester);
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
|
||||
// 2. Перейти на экран создания рецепта
|
||||
await navigateToCreateRecipeScreen(tester);
|
||||
|
||||
// 3. Заполнить только обязательные поля
|
||||
await fillRecipeBasicInfo(
|
||||
tester,
|
||||
title: 'Минимальный тестовый рецепт',
|
||||
type: TypeOfRecipe.firstMeals,
|
||||
portions: 2,
|
||||
calories: 300.0,
|
||||
totalHours: 0,
|
||||
totalMinutes: 30,
|
||||
activeHours: 0,
|
||||
activeMinutes: 15,
|
||||
description: 'Простой рецепт для тестирования.',
|
||||
);
|
||||
|
||||
// 4. Добавить минимум ингредиентов
|
||||
await addIngredients(tester, [
|
||||
{'name': 'Тестовый ингредиент', 'quantity': '100'},
|
||||
]);
|
||||
|
||||
// 5. Симулировать добавление изображения
|
||||
await mockImageSelection(tester);
|
||||
|
||||
// 6. Опубликовать рецепт
|
||||
await publishRecipe(tester);
|
||||
|
||||
// 7. Проверить результат
|
||||
verifyRecipePublished(tester);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/main.dart' as app;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../common_cases/skip_sign_in.dart';
|
||||
import '../common_cases/skip_welcome_screen.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() async {
|
||||
await AppInitializer.initialize(buildType: AppBuildType.tests);
|
||||
});
|
||||
|
||||
testWidgets('qwe', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const app.MyApp());
|
||||
await tester.pumpAndSettle(const Duration(seconds: 3));
|
||||
await skipWelcomeScreen(tester);
|
||||
await skipSignIn(tester);
|
||||
|
||||
final blogPostCreationFinder = find.byKey(AppKeys.feedScreenBlogPostCreationButton);
|
||||
|
||||
expect(blogPostCreationFinder, findsOneWidget, reason: 'Blog post creation button should be found.');
|
||||
|
||||
await tester.tap(blogPostCreationFinder);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
@@ -388,7 +388,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
@@ -406,7 +406,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Flutter",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
|
||||
@@ -539,7 +539,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
@@ -557,7 +557,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Flutter",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
|
||||
@@ -578,7 +578,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
@@ -596,7 +596,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Flutter",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.3;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>74</string>
|
||||
<string>82</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
44
lib/app_keys.dart
Normal file
44
lib/app_keys.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppKeys {
|
||||
// Auth Screen
|
||||
static const hiddenLogoButtonAuthScreen = ValueKey('hiddenLogoButtonAuthScreen');
|
||||
static const emailTextFieldAuthScreen = ValueKey('emailTextFieldAuthScreen');
|
||||
static const passwordTextFieldAuthScreen = ValueKey('passwordTextFieldAuthScreen');
|
||||
static const signInButtonAuthScreen = ValueKey('signInButtonAuthScreen');
|
||||
|
||||
// Welcome Screen
|
||||
static const welcomeScreenButton = ValueKey('welcomeScreenButton');
|
||||
|
||||
// Feed Screen
|
||||
static const feedScreenBlogPostCreationButton = ValueKey('feedScreenBlogPostCreationButton');
|
||||
|
||||
// BlogPostCreateScreen
|
||||
static const blogPostCreateScreenTitleTextField = ValueKey('blogPostCreateScreenTitleTextField');
|
||||
static const blogPostCreateScreenContentTextField = ValueKey('blogPostCreateScreenContentTextField');
|
||||
static const blogPostCreateScreenVideoLinkTextField = ValueKey('blogPostCreateScreenVideoLinkTextField');
|
||||
static const blogPostCreateScreenCreateButton = ValueKey('blogPostCreateScreenCreateButton');
|
||||
static const imageWidget = ValueKey('imageWidget');
|
||||
|
||||
// Recipe Create Screen
|
||||
static const recipeCreateScreenTitleField = ValueKey('recipeCreateScreenTitleField');
|
||||
static const recipeCreateScreenDescriptionField = ValueKey('recipeCreateScreenDescriptionField');
|
||||
static const recipeCreateScreenTypeDropdown = ValueKey('recipeCreateScreenTypeDropdown');
|
||||
static const recipeCreateScreenPortionsField = ValueKey('recipeCreateScreenPortionsField');
|
||||
static const recipeCreateScreenCaloriesField = ValueKey('recipeCreateScreenCaloriesField');
|
||||
static const recipeCreateScreenTotalHoursField = ValueKey('recipeCreateScreenTotalHoursField');
|
||||
static const recipeCreateScreenTotalMinutesField = ValueKey('recipeCreateScreenTotalMinutesField');
|
||||
static const recipeCreateScreenActiveHoursField = ValueKey('recipeCreateScreenActiveHoursField');
|
||||
static const recipeCreateScreenActiveMinutesField = ValueKey('recipeCreateScreenActiveMinutesField');
|
||||
static const recipeCreateScreenTagField = ValueKey('recipeCreateScreenTagField');
|
||||
static const recipeCreateScreenAddTagButton = ValueKey('recipeCreateScreenAddTagButton');
|
||||
static const recipeCreateScreenAddIngredientButton = ValueKey('recipeCreateScreenAddIngredientButton');
|
||||
static const recipeCreateScreenAddStepButton = ValueKey('recipeCreateScreenAddStepButton');
|
||||
static const recipeCreateScreenPublishButton = ValueKey('recipeCreateScreenPublishButton');
|
||||
static const recipeCreateScreenImageWidget = ValueKey('recipeCreateScreenImageWidget');
|
||||
static const recipeCreateScreenStepDescriptionField = ValueKey('recipeCreateScreenStepDescriptionField');
|
||||
static const recipeCreateScreenStepImageWidget = ValueKey('recipeCreateScreenStepImageWidget');
|
||||
static const recipeCreateScreenIngredientNameField = ValueKey('recipeCreateScreenIngredientNameField');
|
||||
static const recipeCreateScreenIngredientQuantityField = ValueKey('recipeCreateScreenIngredientQuantityField');
|
||||
static const recipeCreateScreenIngredientAddButton = ValueKey('recipeCreateScreenIngredientAddButton');
|
||||
}
|
||||
502
lib/common/widgets/markdown_editor.dart
Normal file
502
lib/common/widgets/markdown_editor.dart
Normal file
@@ -0,0 +1,502 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
|
||||
class MarkdownEditor extends StatefulWidget {
|
||||
const MarkdownEditor({
|
||||
super.key,
|
||||
this.initialText,
|
||||
this.hintText,
|
||||
this.hasError = false,
|
||||
this.minLines = 4,
|
||||
this.maxLines = 6,
|
||||
this.heightPreview = 120,
|
||||
this.onChanged,
|
||||
this.onToolbarVisibilityChanged,
|
||||
this.showInlineToolbar = true,
|
||||
});
|
||||
|
||||
final String? initialText;
|
||||
final String? hintText;
|
||||
final bool hasError;
|
||||
final int minLines;
|
||||
final int maxLines;
|
||||
final double heightPreview;
|
||||
final void Function(String value)? onChanged;
|
||||
final void Function(bool visible)? onToolbarVisibilityChanged;
|
||||
final bool showInlineToolbar;
|
||||
|
||||
@override
|
||||
State<MarkdownEditor> createState() => MarkdownEditorState();
|
||||
}
|
||||
|
||||
class MarkdownEditorState extends State<MarkdownEditor> {
|
||||
late final TextEditingController _controller;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isPreview = false;
|
||||
bool _enablePanel = false;
|
||||
late final KeyboardVisibilityController _keyboardVisibilityController;
|
||||
late final StreamSubscription<bool> _keyboardSubscription;
|
||||
|
||||
bool get isToolbarVisible => _enablePanel || _isPreview;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialText ?? '');
|
||||
_keyboardVisibilityController = KeyboardVisibilityController();
|
||||
_keyboardSubscription = _keyboardVisibilityController.onChange.listen((visible) {
|
||||
setState(() {
|
||||
_enablePanel = visible;
|
||||
});
|
||||
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardSubscription.cancel();
|
||||
_focusNode.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _hasFormatting(String prefix, String suffix) {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
||||
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
||||
return beforeCursor == prefix && afterCursor == suffix;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _removeFormatting(String prefix, String suffix) {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
||||
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||
final newText = beforeCursor + afterCursor;
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection.collapsed(offset: cursorPosition - prefix.length);
|
||||
}
|
||||
}
|
||||
widget.onChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void _applyMarkdownFormat(String prefix, String suffix) {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final formattedText = '$prefix$selectedText$suffix';
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection.collapsed(offset: cursorPosition + prefix.length);
|
||||
}
|
||||
widget.onChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void _applyHeading() {
|
||||
if (_hasFormatting('# ', '')) {
|
||||
_removeFormatting('# ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('# ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBold() {
|
||||
if (_hasFormatting('**', '**')) {
|
||||
_removeFormatting('**', '**');
|
||||
} else {
|
||||
_applyMarkdownFormat('**', '**');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyItalic() {
|
||||
if (_hasFormatting('*', '*')) {
|
||||
_removeFormatting('*', '*');
|
||||
} else {
|
||||
_applyMarkdownFormat('*', '*');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyNumberedList() {
|
||||
if (_hasFormatting('1. ', '')) {
|
||||
_removeFormatting('1. ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('1. ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBulletedList() {
|
||||
if (_hasFormatting('- ', '')) {
|
||||
_removeFormatting('- ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('- ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyLink() {
|
||||
final text = _controller.text;
|
||||
final selection = _controller.selection;
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
|
||||
if (linkRegex.hasMatch(selectedText)) {
|
||||
final match = linkRegex.firstMatch(selectedText);
|
||||
final linkText = match?.group(1) ?? selectedText;
|
||||
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
||||
} else {
|
||||
final formattedText = '[$selectedText](url)';
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
||||
}
|
||||
} else {
|
||||
_applyMarkdownFormat('[', '](url)');
|
||||
}
|
||||
widget.onChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void _applyHorizontalRule() {
|
||||
final cursorPosition = _controller.selection.baseOffset;
|
||||
final text = _controller.text;
|
||||
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||
_controller.text = newText;
|
||||
_controller.selection = TextSelection.collapsed(offset: cursorPosition + 5);
|
||||
widget.onChanged?.call(_controller.text);
|
||||
}
|
||||
|
||||
void _togglePreview() {
|
||||
setState(() {
|
||||
_isPreview = !_isPreview;
|
||||
});
|
||||
if (!_isPreview) {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
} else {
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
||||
}
|
||||
|
||||
Widget _mdButton(IconData icon, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
child: Icon(icon),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mdTextButton(String text, VoidCallback onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMarkdownPreview(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.heightPreview,
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: SingleChildScrollView(child: _parseMarkdown(_controller.text)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildToolbar(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||
),
|
||||
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _parseMarkdown(String text) {
|
||||
if (text.isEmpty) {
|
||||
return Text(widget.hintText ?? '');
|
||||
}
|
||||
final lines = text.split('\n');
|
||||
final widgets = <Widget>[];
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
if (line.trim().isEmpty) {
|
||||
widgets.add(const SizedBox(height: 8));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(2),
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(3),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(4),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(fontSize: 16)),
|
||||
Expanded(child: _parseInlineMarkdown(line.substring(2))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
|
||||
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
|
||||
final number = match?.group(1) ?? '1';
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
||||
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (line.trim() == '---') {
|
||||
widgets.add(const Divider(color: Colors.grey, thickness: 1));
|
||||
} else {
|
||||
widgets.add(_parseInlineMarkdown(line));
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
widgets.add(const SizedBox(height: 4));
|
||||
}
|
||||
}
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
||||
}
|
||||
|
||||
Widget _parseInlineMarkdown(String text) {
|
||||
final spans = <TextSpan>[];
|
||||
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
|
||||
int lastEnd = 0;
|
||||
for (final match in regex.allMatches(text)) {
|
||||
if (match.start > lastEnd) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(lastEnd, match.start),
|
||||
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
);
|
||||
}
|
||||
final matchedText = match.group(0)!;
|
||||
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(2, matchedText.length - 2),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(1, matchedText.length - 1),
|
||||
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
|
||||
if (linkMatch != null) {
|
||||
final linkText = linkMatch.group(1)!;
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: linkText,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
lastEnd = match.end;
|
||||
}
|
||||
if (lastEnd < text.length) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(lastEnd),
|
||||
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RichText(text: TextSpan(children: spans));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_isPreview)
|
||||
_buildMarkdownPreview(context)
|
||||
else
|
||||
CustomTextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
hintText: widget.hintText,
|
||||
hasError: widget.hasError,
|
||||
onChanged: (value) => widget.onChanged?.call(value),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.showInlineToolbar && (isToolbarVisible || !_isPreview))
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||
],
|
||||
),
|
||||
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||
@@ -13,8 +16,24 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
/// Конфигурация приложения
|
||||
class AppConfig {
|
||||
AppConfig({required this.buildType}) {
|
||||
_baseUrl = switch (buildType) {
|
||||
AppBuildType.prod => EnvConfig.baseUrl,
|
||||
AppBuildType.tests => EnvConfig.devBaseUrl,
|
||||
AppBuildType.dev => EnvConfig.devBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
final AppBuildType buildType;
|
||||
late String _baseUrl;
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
static List<BlocProvider> get globalBlocProviders => [
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<NewMessagesCubit>(create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(create: (context) => sl<NotificationsCubit>()..getNotifications()),
|
||||
BlocProvider<FeedCubit>(create: (context) => sl<FeedCubit>()..loadFeedBlogPostList()),
|
||||
@@ -22,10 +41,11 @@ class AppConfig {
|
||||
BlocProvider<ChatsCubit>(create: (context) => sl<ChatsCubit>()..getChatRoomsWithLoading()),
|
||||
BlocProvider<ViewsCubit>(create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(create: (context) => sl<MarketingCubit>()),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
static const List<LocalizationsDelegate> localizationDelegates = [
|
||||
List<LocalizationsDelegate> get localizationDelegates => [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
@@ -33,5 +53,5 @@ class AppConfig {
|
||||
];
|
||||
|
||||
/// Поддерживаемые локали
|
||||
static const List<Locale> supportedLocales = [Locale.fromSubtags(languageCode: 'ru')];
|
||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
@@ -13,11 +15,11 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
/// Класс для инициализации приложения
|
||||
class AppInitializer {
|
||||
/// Инициализация всех сервисов приложения
|
||||
static Future<void> initialize() async {
|
||||
await _initializeSentry();
|
||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||
await _initializeEnvironment();
|
||||
await _initializeDependencyInjection(buildType: buildType);
|
||||
await _initializeSentry();
|
||||
await _initializeFirebase();
|
||||
await _initializeDependencyInjection();
|
||||
await _initializeDebugMode();
|
||||
await _initializeAnalytics();
|
||||
}
|
||||
@@ -36,12 +38,14 @@ class AppInitializer {
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
/// Инициализация системы внедрения зависимостей
|
||||
static Future<void> _initializeDependencyInjection() async {
|
||||
await di.init();
|
||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
||||
await di.init(buildType: buildType);
|
||||
}
|
||||
|
||||
/// Настройка режима отладки
|
||||
@@ -66,3 +70,5 @@ class AppInitializer {
|
||||
}, appRunner: appRunner);
|
||||
}
|
||||
}
|
||||
|
||||
enum AppBuildType { prod, tests, dev }
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/routing/main_navigation_routes.dart';
|
||||
import 'package:eatvio_app/core/routing/other_routes.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/core/routing/settings_routes.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -28,26 +29,52 @@ class AppRouter {
|
||||
/// Обработка редиректов для deep links
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
final segments = state.uri.pathSegments;
|
||||
if (segments.length >= 3) {
|
||||
if (segments.length == 4) {
|
||||
final type = segments[1];
|
||||
switch (type) {
|
||||
case 'diary':
|
||||
FlutterNativeSplash.remove();
|
||||
return '/diary-screen';
|
||||
}
|
||||
}
|
||||
if (segments.length == 3) {
|
||||
final type = segments[1];
|
||||
final contentIdentifier = segments[2];
|
||||
|
||||
switch (type) {
|
||||
case 'recipe':
|
||||
FlutterNativeSplash.remove();
|
||||
return '${RouteConstants.aboutRecipesScreen}?slug=$contentIdentifier';
|
||||
return '/about-recipes-screen?slug=$contentIdentifier';
|
||||
case 'blog_post':
|
||||
FlutterNativeSplash.remove();
|
||||
return '${RouteConstants.blogPostScreen}?id=$contentIdentifier';
|
||||
return '/blog-post-screen?id=$contentIdentifier';
|
||||
case 'plan':
|
||||
FlutterNativeSplash.remove();
|
||||
return '${RouteConstants.aboutPlanScreen}?slug=$contentIdentifier';
|
||||
return '/about-plan-screen?slug=$contentIdentifier';
|
||||
case 'diet':
|
||||
FlutterNativeSplash.remove();
|
||||
return '${RouteConstants.aboutDietScreen}?slug=$contentIdentifier';
|
||||
return '/about-diet-screen?slug=$contentIdentifier';
|
||||
case 'chat':
|
||||
FlutterNativeSplash.remove();
|
||||
return '/chat-select-screen';
|
||||
case 'profile':
|
||||
FlutterNativeSplash.remove();
|
||||
return '/profile-screen?slug=$contentIdentifier';
|
||||
}
|
||||
}
|
||||
if (segments.length == 2) {
|
||||
final type = segments[1];
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
FlutterNativeSplash.remove();
|
||||
return '/chat-select-screen';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) {
|
||||
appRouter.pushNamed('registerViaSocialScreen', extra: {'authResult': authResult, 'provider': provider});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
|
||||
String baseUrl = EnvConfig.devBaseUrl;
|
||||
bool hidePlans = const bool.fromEnvironment('HIDE_PLANS', defaultValue: false);
|
||||
const buildDate = String.fromEnvironment('BUILD_DATE', defaultValue: '');
|
||||
|
||||
|
||||
29
lib/core/cubit/marketing/marketing_cubit.dart
Normal file
29
lib/core/cubit/marketing/marketing_cubit.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit_state.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class MarketingCubit extends Cubit<MarketingCubitState> {
|
||||
final Prefs prefs;
|
||||
MarketingCubit(this.prefs) : super(const MarketingCubitState());
|
||||
|
||||
Future<bool> checkRegistrationDate() async {
|
||||
try {
|
||||
final milliseconds = prefs.getInt(PrefsKeys.registrationDate);
|
||||
final marketingBottomSheetHasBeenShown = prefs.getBool(PrefsKeys.marketingBottomSheetHasBeenShown);
|
||||
if (milliseconds == null || marketingBottomSheetHasBeenShown == true) {
|
||||
return false;
|
||||
}
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(milliseconds);
|
||||
final today = DateTime.now();
|
||||
final daysCount = today.difference(date).inDays;
|
||||
final needToShowMarketingBottomSheet = daysCount >= 14;
|
||||
if (needToShowMarketingBottomSheet) {
|
||||
await prefs.setBool(PrefsKeys.marketingBottomSheetHasBeenShown, true);
|
||||
}
|
||||
return needToShowMarketingBottomSheet;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
lib/core/cubit/marketing/marketing_cubit_state.dart
Normal file
3
lib/core/cubit/marketing/marketing_cubit_state.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
class MarketingCubitState {
|
||||
const MarketingCubitState();
|
||||
}
|
||||
12
lib/core/di/app_config_registration.dart
Normal file
12
lib/core/di/app_config_registration.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class AppConfigRegistration {
|
||||
static final sl = GetIt.instance;
|
||||
|
||||
/// Регистрация AppConfig
|
||||
static void registerAppConfig({required AppBuildType buildType}) {
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: buildType));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
@@ -29,6 +31,7 @@ import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/cubit/archive/consultations_archive_cubit.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_cubit.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.dart';
|
||||
@@ -36,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
@@ -72,10 +76,10 @@ class CubitRegistrations {
|
||||
static void _registerFactoryCubits() {
|
||||
// Auth related cubits
|
||||
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl()));
|
||||
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl()));
|
||||
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
|
||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl()));
|
||||
|
||||
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
|
||||
// Feed related cubits
|
||||
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
|
||||
@@ -128,7 +132,7 @@ class CubitRegistrations {
|
||||
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
|
||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
@@ -140,5 +144,8 @@ class CubitRegistrations {
|
||||
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
|
||||
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
|
||||
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/dio/phone_verify_error_interceptor.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/interceptors/phone_verify_error_interceptor.dart';
|
||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Регистрация внешних зависимостей в системе внедрения зависимостей
|
||||
class ExternalRegistrations {
|
||||
@@ -11,23 +13,20 @@ class ExternalRegistrations {
|
||||
/// Регистрация всех внешних зависимостей
|
||||
static void registerAll() {
|
||||
_registerDio();
|
||||
_registerPrefs();
|
||||
}
|
||||
|
||||
/// Регистрация Dio HTTP клиента
|
||||
static void _registerDio() {
|
||||
sl.registerLazySingleton(
|
||||
() => Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
),
|
||||
)..interceptors.addAll([
|
||||
TokenInterceptor(),
|
||||
PhoneVerifyErrorInterceptor(),
|
||||
]),
|
||||
() =>
|
||||
Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl, headers: {'accept': 'application/json', 'content-type': 'application/json'}))
|
||||
..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Prefs
|
||||
static void _registerPrefs() {
|
||||
sl.registerSingletonAsync<Prefs>(()async => Prefs(await SharedPreferences.getInstance()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
||||
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
@@ -68,6 +69,7 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||
|
||||
// Content repositories
|
||||
sl.registerLazySingleton<BlogPostRepository>(() => BlogPostRepository(blogPostService: sl()));
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
||||
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
||||
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
||||
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
||||
import 'package:eatvio_app/features/profile/services/profile_service.dart';
|
||||
@@ -76,6 +77,7 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||
|
||||
// Content services
|
||||
sl.registerLazySingleton<CreateRecipeService>(() => CreateRecipeService(sl()));
|
||||
|
||||
@@ -45,7 +45,8 @@ class EnvConfig {
|
||||
static String get yandexCheckUrl => dotenv.env['YANDEX_CHECK_URL'] ?? '';
|
||||
|
||||
// VK ID Configuration
|
||||
static String get vkClientId => dotenv.env['VK_CLIENT_ID'] ?? '';
|
||||
static String get vkClientIdIos => dotenv.env['VK_CLIENT_ID_IOS'] ?? '';
|
||||
static String get vkClientIdAndroid => dotenv.env['VK_CLIENT_ID_ANDROID'] ?? '';
|
||||
|
||||
// Yandex ID Configuration
|
||||
static String get yandexClientId => dotenv.env['YANDEX_CLIENT_ID'] ?? '';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -19,12 +21,30 @@ class FirebaseApi {
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative();
|
||||
} else {
|
||||
await _firebaseMessaging.requestPermission();
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
await _firebaseMessaging.requestPermission();
|
||||
}
|
||||
token = await _firebaseMessaging.getToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
|
||||
@@ -12,8 +12,8 @@ class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||
final data = err.response?.data;
|
||||
|
||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||
final String passKey = await sl<ProfileRepository>().getPassKey(phone);
|
||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
||||
await showErrorDialog(AppRouter.navigatorKey.currentContext!,
|
||||
title: 'Привяжите номер телефона', description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона');
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/repository_registrations.dart';
|
||||
@@ -9,19 +11,22 @@ import 'package:get_it/get_it.dart';
|
||||
final sl = GetIt.instance;
|
||||
|
||||
/// Инициализация системы внедрения зависимостей
|
||||
Future<void> init() async {
|
||||
Future<void> init({required AppBuildType buildType}) async {
|
||||
// Регистрация AppConfig
|
||||
AppConfigRegistration.registerAppConfig(buildType: buildType);
|
||||
|
||||
// Регистрация внешних зависимостей (Dio, etc.)
|
||||
ExternalRegistrations.registerAll();
|
||||
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
|
||||
|
||||
// Регистрация репозиториев
|
||||
RepositoryRegistrations.registerAll();
|
||||
|
||||
|
||||
// Регистрация Cubit'ов
|
||||
CubitRegistrations.registerAll();
|
||||
|
||||
|
||||
// Регистрация WebSocket менеджера
|
||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl()));
|
||||
}
|
||||
}
|
||||
|
||||
11
lib/core/mixins/registration_date_cache_mixin.dart
Normal file
11
lib/core/mixins/registration_date_cache_mixin.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
|
||||
mixin RegistrationDateCacheMixin {
|
||||
late final Prefs prefs;
|
||||
|
||||
|
||||
Future<void> cacheRegistrationDate(DateTime date) async {
|
||||
await prefs.setInt(PrefsKeys.registrationDate, date.millisecondsSinceEpoch);
|
||||
}
|
||||
}
|
||||
43
lib/core/prefs.dart
Normal file
43
lib/core/prefs.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
||||
class Prefs {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
Prefs(this._prefs);
|
||||
|
||||
String _key(PrefsKeys key) => key.name;
|
||||
|
||||
Future<void> setString(PrefsKeys key, String value) async {
|
||||
await _prefs.setString(_key(key), value);
|
||||
}
|
||||
|
||||
String? getString(PrefsKeys key) {
|
||||
return _prefs.getString(_key(key));
|
||||
}
|
||||
|
||||
Future<void> setInt(PrefsKeys key, int value) async {
|
||||
await _prefs.setInt(_key(key), value);
|
||||
}
|
||||
|
||||
int? getInt(PrefsKeys key) {
|
||||
return _prefs.getInt(_key(key));
|
||||
}
|
||||
|
||||
Future<void> setBool(PrefsKeys key, bool value) async {
|
||||
await _prefs.setBool(_key(key), value);
|
||||
}
|
||||
|
||||
bool? getBool(PrefsKeys key) {
|
||||
return _prefs.getBool(_key(key));
|
||||
}
|
||||
|
||||
Future<void> remove(PrefsKeys key) async {
|
||||
await _prefs.remove(_key(key));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _prefs.clear();
|
||||
}
|
||||
}
|
||||
1
lib/core/prefs_keys.dart
Normal file
1
lib/core/prefs_keys.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/auth_screen.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/forget_password_screen.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/register_via_social_screen.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/scan_qr_code_screen.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/pages/registration_screen.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/pages/verify_screen.dart';
|
||||
@@ -16,6 +20,10 @@ class AuthRoutes {
|
||||
path: RouteConstants.authScreen,
|
||||
name: 'authScreen',
|
||||
builder: (context, state) => const AuthScreen(),
|
||||
redirect: (context, state) {
|
||||
sl<FirebaseApi>().registerAnonymousPushToken();
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
|
||||
GoRoute(
|
||||
@@ -32,6 +40,14 @@ class AuthRoutes {
|
||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||
),
|
||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.registerViaSocialScreen,
|
||||
name: 'registerViaSocialScreen',
|
||||
builder: (context, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
return RegisterViaSocialScreen(authResult: extra?['authResult'] as AuthResultEntity?, provider: extra?['provider'] as String?);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,15 @@ class ContentRoutes {
|
||||
builder: (context, state) {
|
||||
final slug = state.uri.queryParameters['slug'];
|
||||
if (slug != null) {
|
||||
return AboutDietScreen(slug: slug, index: null, navigateToComments: false);
|
||||
return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: false);
|
||||
}
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return AboutDietScreen(slug: data['slug'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
|
||||
return AboutDietScreen(
|
||||
id: data['id'] as int?,
|
||||
slug: data['slug'] as String?,
|
||||
index: data['index'] as int?,
|
||||
navigateToComments: data['navigateToComments'] as bool,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/calendar/presentation/pages/calendar_screen.dart';
|
||||
@@ -27,6 +29,8 @@ import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/user_finance_screen.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/pages/my_recipes_screen.dart';
|
||||
@@ -40,7 +44,6 @@ import 'package:eatvio_app/features/finances_and_consultations_archive/presentat
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
/// Остальные маршруты приложения
|
||||
@@ -55,18 +58,24 @@ class OtherRoutes {
|
||||
builder: (context, state) => const WelcomeScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final welcomeScreenWasShowed = sharedPreferences.getBool('welcomeScreenWasShowed') ?? false;
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
if (welcomeScreenWasShowed == false) {
|
||||
final prefs = sl<Prefs>();
|
||||
final firebaseApi = sl<FirebaseApi>();
|
||||
final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
|
||||
await firebaseApi.registerPushToken();
|
||||
if (!welcomeScreenWasShowed) {
|
||||
return null;
|
||||
} else if (await TokenStorage.haveTokenInStorage()) {
|
||||
return RouteConstants.feedScreen;
|
||||
return '/feed-screen';
|
||||
} else {
|
||||
return RouteConstants.authScreen;
|
||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
if (!hasAnonymousTokenBeenSent) {
|
||||
await firebaseApi.registerAnonymousPushToken();
|
||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
}
|
||||
return '/auth-screen';
|
||||
}
|
||||
} catch (e) {
|
||||
return RouteConstants.feedScreen;
|
||||
return '/feed-screen';
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -76,7 +85,7 @@ class OtherRoutes {
|
||||
builder: (context, state) => const UpdateScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final versions = (await sl<Dio>().get('$baseUrl/api/app_versions')).data;
|
||||
final versions = (await sl<Dio>().get('${sl<AppConfig>().baseUrl}/api/app_versions')).data;
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
String version = packageInfo.version;
|
||||
Version currentVersion = Version.parse(version);
|
||||
@@ -139,7 +148,13 @@ class OtherRoutes {
|
||||
GoRoute(
|
||||
path: RouteConstants.profileScreen,
|
||||
name: 'profileScreen',
|
||||
builder: (context, state) => ProfileScreen(author: state.extra as Author),
|
||||
builder: (context, state) {
|
||||
final slug = state.uri.queryParameters['slug'];
|
||||
if (slug != null) {
|
||||
return ProfileScreen(slug: slug);
|
||||
}
|
||||
return ProfileScreen(slug: state.extra as String);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.subsScreen,
|
||||
@@ -161,7 +176,7 @@ class OtherRoutes {
|
||||
GoRoute(
|
||||
path: RouteConstants.emptyChatScreen,
|
||||
name: 'emptyChatScreen',
|
||||
builder: (context, state) => EmptyChatScreen(author: state.extra as Author),
|
||||
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
|
||||
),
|
||||
|
||||
// Consultant routes
|
||||
@@ -180,7 +195,7 @@ class OtherRoutes {
|
||||
),
|
||||
|
||||
// Other routes
|
||||
GoRoute(path: RouteConstants.glossaryScreen, name: 'glossaryScreen', builder: (context, state) => const GlossaryScreen()),
|
||||
GoRoute(path: RouteConstants.glossaryScreen, name: 'glossaryScreen', builder: (context, state) => GlossaryScreen(passKey: state.extra as String)),
|
||||
GoRoute(path: RouteConstants.blockedUsersScreen, name: 'blockedUsersScreen', builder: (context, state) => const BlockedUsersScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.notificationsSettingsScreen,
|
||||
@@ -201,7 +216,8 @@ class OtherRoutes {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final planId = data['planId'] as int;
|
||||
final successfulPayment = data['successfulPayment'] as bool?;
|
||||
return InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment);
|
||||
final customerSlug = data['customerSlug'] as String?;
|
||||
return InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment, customerSlug: customerSlug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -224,6 +240,11 @@ class OtherRoutes {
|
||||
return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: PersonalDietSuggestionScreen.path,
|
||||
name: PersonalDietSuggestionScreen.name,
|
||||
builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int,),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class RouteConstants {
|
||||
static const String scanQRCodeScreen = 'scan-qr-code-screen';
|
||||
static const String verifyScreen = '/verify-screen';
|
||||
static const String verifyEmailScreen = '/verify-email-screen';
|
||||
static const String registerViaSocialScreen = '/register-via-social-screen';
|
||||
|
||||
// Main navigation routes
|
||||
static const String myProfileScreen = '/my-profile-screen';
|
||||
|
||||
@@ -249,4 +249,10 @@ abstract class TextStyles {
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.255,
|
||||
);
|
||||
|
||||
static const textFS24FW700 = TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.255,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ class PrimaryColors {
|
||||
// Blue
|
||||
Color get blue400 => const Color(0XFF43A6F2);
|
||||
|
||||
Color get blueA400 => const Color(0XFF2F80ED);
|
||||
Color get blueA400 => const Color(0XFF2F80ED);
|
||||
// BlueGray
|
||||
Color get blueGray400 => const Color(0XFF7B8794);
|
||||
|
||||
@@ -141,6 +141,8 @@ class PrimaryColors {
|
||||
|
||||
Color get gray60003 => const Color(0XFF707070);
|
||||
|
||||
Color get gray60005 => const Color(0xFF6C7C93);
|
||||
|
||||
// Green
|
||||
Color get greenA700 => const Color(0XFF019A3E);
|
||||
|
||||
@@ -148,6 +150,8 @@ class PrimaryColors {
|
||||
|
||||
Color get green => const Color(0XFF0BB450);
|
||||
|
||||
Color get transparentGreen => const Color.fromARGB(12, 1, 154, 63);
|
||||
|
||||
// LightGreen
|
||||
Color get lightGreen300 => const Color(0XFFB2DF8A);
|
||||
|
||||
|
||||
@@ -46,6 +46,17 @@ extension DateTimeExtension on DateTime {
|
||||
final now = DateTime.now();
|
||||
return now.year == localDate.year && now.month == localDate.month && now.day == localDate.day;
|
||||
}
|
||||
|
||||
String toISO8601WithTimeZoneOffset() {
|
||||
final formatter = DateFormat("yyyy-MM-ddTHH:mm:ss");
|
||||
final formatted = formatter.format(this);
|
||||
final offset = timeZoneOffset;
|
||||
final hours = offset.inHours.abs().toString().padLeft(2, '0');
|
||||
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
|
||||
final sign = offset.isNegative ? '-' : '+';
|
||||
|
||||
return "$formatted$sign$hours:$minutes";
|
||||
}
|
||||
}
|
||||
|
||||
String calculateDifference(DateTime dateTime, [String? format]) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
|
||||
import 'package:dart_pusher_channels/dart_pusher_channels.dart';
|
||||
import 'package:easy_debounce/easy_debounce.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_service.dart';
|
||||
@@ -111,7 +112,7 @@ class WsManager {
|
||||
_privateNotificationChannel = client?.privateChannel(
|
||||
'$newNotificationsChannelName.$userId',
|
||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||
authorizationEndpoint: Uri.parse('$baseUrl/broadcasting/auth'),
|
||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
@@ -124,7 +125,7 @@ class WsManager {
|
||||
_privateMessagesChannel = client?.privateChannel(
|
||||
'$newMessagesChannelName.$userId',
|
||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||
authorizationEndpoint: Uri.parse('$baseUrl/broadcasting/auth'),
|
||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
@@ -137,7 +138,7 @@ class WsManager {
|
||||
_privateDirectChatChannel = client?.privateChannel(
|
||||
'$directChatChannelName.$chatId',
|
||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||
authorizationEndpoint: Uri.parse('$baseUrl/broadcasting/auth'),
|
||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -86,7 +86,7 @@ class CustomPopup extends StatelessWidget {
|
||||
|
||||
if (isAuthor) {
|
||||
addItem('assets/icons/copy2.svg', 'Скопировать ссылку', () {
|
||||
Clipboard.setData(ClipboardData(text: '$baseUrl/ru/$path'));
|
||||
Clipboard.setData(ClipboardData(text: '${sl<AppConfig>().baseUrl}/ru/$path'));
|
||||
showInfoToast(s.link_was_copied);
|
||||
});
|
||||
addItem('assets/icons/share2.svg', 'Поделиться', () {
|
||||
@@ -94,7 +94,7 @@ class CustomPopup extends StatelessWidget {
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse(
|
||||
'$baseUrl/ru/$path',
|
||||
'${sl<AppConfig>().baseUrl}/ru/$path',
|
||||
),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
@@ -140,7 +140,7 @@ class CustomPopup extends StatelessWidget {
|
||||
});
|
||||
} else {
|
||||
addItem('assets/icons/copy2.svg', 'Скопировать ссылку', () {
|
||||
Clipboard.setData(ClipboardData(text: '$baseUrl/ru/$path'));
|
||||
Clipboard.setData(ClipboardData(text: '${sl<AppConfig>().baseUrl}/ru/$path'));
|
||||
showInfoToast(s.link_was_copied);
|
||||
});
|
||||
addItem('assets/icons/share2.svg', 'Поделиться', () {
|
||||
@@ -148,7 +148,7 @@ class CustomPopup extends StatelessWidget {
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse(
|
||||
'$baseUrl/ru/$path',
|
||||
'${sl<AppConfig>().baseUrl}/ru/$path',
|
||||
),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
|
||||
124
lib/core/widgets/marketing_bottom_sheet.dart
Normal file
124
lib/core/widgets/marketing_bottom_sheet.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
void showMarketingBottomSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom + 20),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: appTheme.whiteA700),
|
||||
child: const MarketingBottomSheet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class MarketingBottomSheet extends StatelessWidget {
|
||||
const MarketingBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsetsGeometry.all(4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
image: DecorationImage(image: AssetImage('assets/images/logo_bg.png'), fit: BoxFit.cover),
|
||||
),
|
||||
child: Center(child: SvgPicture.asset('assets/icons/logo_2.svg', height: 90)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(s.congratulations, style: TextStyles.textFS24FW700.copyWith(color: appTheme.blueA400)),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.lightGray0, borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Container(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: appTheme.blueA400),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text('14', style: TextStyles.textFS24FW700.copyWith(color: appTheme.whiteA700)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
text: s.youWithUs,
|
||||
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.gray60005),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: s.fourteenDays,
|
||||
style: TextStyles.textLMedium.copyWith(color: appTheme.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
s.weAreLookingFor,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
s.letsInvite,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
|
||||
child: CustomOutlinedButton(
|
||||
text: s.invite,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
leftIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Transform.rotate(
|
||||
angle: 3.14,
|
||||
child: SvgPicture.asset('assets/icons/share.svg', width: 14, height: 14, colorFilter: ColorFilter.mode(appTheme.whiteA700, BlendMode.srcIn)),
|
||||
),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () async {
|
||||
SharePlus.instance.share(ShareParams(uri: Uri.parse('https://promo.eatvio.ru/')));
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
s.eatvioIsAHelthyHabbit,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.blueA400),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class OptionWidget extends StatelessWidget {
|
||||
const OptionWidget({super.key, required this.option, this.disableDuration = false});
|
||||
const OptionWidget({super.key, required this.option, this.disableDuration = false, required this.duration});
|
||||
final PacketOptions? option;
|
||||
final bool disableDuration;
|
||||
final int duration;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (option == null) {
|
||||
@@ -34,8 +35,12 @@ class OptionWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!disableDuration) ...[
|
||||
const _OptionDurationWidget(duration: 7),
|
||||
const SizedBox(height: 16),
|
||||
_OptionDurationWidget(duration: duration),
|
||||
SizedBox(height: option?.hasPersonalDiet ?? false ? 8 : 12),
|
||||
],
|
||||
if ((option?.hasPersonalDiet ?? false) && !disableDuration) ...[
|
||||
const _OptionHasPersonalDietWidget(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const CustomDivider(),
|
||||
const SizedBox(height: 16),
|
||||
@@ -52,7 +57,6 @@ class OptionWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _OptionDurationWidget extends StatelessWidget {
|
||||
const _OptionDurationWidget({required this.duration});
|
||||
final int duration;
|
||||
@@ -92,3 +96,43 @@ class _OptionDurationWidget extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionHasPersonalDietWidget extends StatelessWidget {
|
||||
const _OptionHasPersonalDietWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: appTheme.transparentGreen),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.green,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(100),
|
||||
bottomRight: Radius.circular(100),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
Text(
|
||||
s.personalDietCreating,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ abstract class AuthResultEntity with _$AuthResultEntity {
|
||||
const factory AuthResultEntity({
|
||||
required String accessToken,
|
||||
required String userId,
|
||||
required String clientId,
|
||||
String? refreshToken,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
|
||||
16
lib/features/auth/models/oauth_login_request.dart
Normal file
16
lib/features/auth/models/oauth_login_request.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_login_request.freezed.dart';
|
||||
part 'oauth_login_request.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthLoginRequest with _$OAuthLoginRequest {
|
||||
const factory OAuthLoginRequest({
|
||||
@JsonKey(name: 'client_id') required String clientId,
|
||||
@JsonKey(name: 'access_token') required String accessToken,
|
||||
@JsonKey(name: 'refresh_token') required String refreshToken,
|
||||
required String provider,
|
||||
}) = _OAuthLoginRequest;
|
||||
|
||||
factory OAuthLoginRequest.fromJson(Map<String, dynamic> json) => _$OAuthLoginRequestFromJson(json);
|
||||
}
|
||||
25
lib/features/auth/models/oauth_model.dart
Normal file
25
lib/features/auth/models/oauth_model.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_model.freezed.dart';
|
||||
part 'oauth_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthModel with _$OAuthModel {
|
||||
const factory OAuthModel({
|
||||
@JsonKey(name: 'access_token') required String accessToken,
|
||||
@JsonKey(name: 'refresh_token') required String refreshToken,
|
||||
@JsonKey(name: 'first_name') required String firstName,
|
||||
@JsonKey(name: 'last_name') required String lastName,
|
||||
@JsonKey(name: 'date_of_birth') required String dateOfBirth,
|
||||
@JsonKey(name: 'client_id') required String clientId,
|
||||
required String provider,
|
||||
required String id,
|
||||
required String email,
|
||||
required String phone,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
}) = _OAuthModel;
|
||||
|
||||
factory OAuthModel.fromJson(Map<String, dynamic> json) => _$OAuthModelFromJson(json);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ abstract class YandexProfileModel with _$YandexProfileModel {
|
||||
@JsonKey(name: 'real_name') required String realName,
|
||||
@JsonKey(name: 'first_name') required String firstName,
|
||||
@JsonKey(name: 'last_name') required String lastName,
|
||||
@JsonKey(name: 'sex') required String sex,
|
||||
@JsonKey(name: 'sex') String? sex,
|
||||
@JsonKey(name: 'default_email') required String defaultEmail,
|
||||
@JsonKey(name: 'default_phone') required YandexPhoneData defaultPhone,
|
||||
}) = _YandexProfileModel;
|
||||
|
||||
@@ -25,19 +25,4 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> getTokenBySocial({required String provider, required String accessToken}) async {
|
||||
emit(const AuthState.loading());
|
||||
try {
|
||||
final token = (await authRepository.getTokenBySocial(provider: provider, accessToken: accessToken)).token;
|
||||
emit(AuthState.loaded(token: token));
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
showErrorToast('Не удалось войти через $provider');
|
||||
emit(AuthState.error(errorMessage: '$e'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart
Normal file
106
lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:developer';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
final AuthRepository authRepository;
|
||||
|
||||
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
|
||||
this.prefs = prefs;
|
||||
}
|
||||
|
||||
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
|
||||
final tokenModel = await authRepository.signInViaSocialMedia(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
clientId: clientId,
|
||||
);
|
||||
|
||||
log('OAuthCubit: Got token model: ${tokenModel.token}');
|
||||
TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
emit(OAuthState.loaded(token: tokenModel.token));
|
||||
} catch (e) {
|
||||
log('OAuthCubit: Error occurred: $e');
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<RegistrationValidationErrors>?> signUpViaSocialMedia({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String id,
|
||||
required String refreshToken,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
required String phone,
|
||||
required String dateOfBirth,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
required String clientId,
|
||||
}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
final token = await authRepository.signUpViaSocialMedia(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
id: id,
|
||||
refreshToken: refreshToken,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: email,
|
||||
phone: phone,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
weight: weight,
|
||||
height: height,
|
||||
clientId: clientId,
|
||||
);
|
||||
TokenStorage.saveTokenInStorage(token);
|
||||
cacheRegistrationDate(DateTime.now());
|
||||
emit(OAuthState.loaded(token: token));
|
||||
} on DioException catch (e) {
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
final errors = e.response?.data['errors'] as Map<String, dynamic>?;
|
||||
final emailErrors = errors?['email'] as List<dynamic>?;
|
||||
final phoneErrors = errors?['phone'] as List<dynamic>?;
|
||||
final dateOfBirth = errors?['date_of_birth'] as List<dynamic>?;
|
||||
final errorsList = <RegistrationValidationErrors>[];
|
||||
if (emailErrors != null && emailErrors.isNotEmpty) {
|
||||
if (emailErrors.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (emailErrors.contains('Такой Эл. почта уже существует.')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
}
|
||||
}
|
||||
if (phoneErrors != null && phoneErrors.isNotEmpty) {
|
||||
if (phoneErrors.contains('Такой phone уже существует.')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
}
|
||||
}
|
||||
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
|
||||
if (dateOfBirth.contains('Некорректный формат даты рождения')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat);
|
||||
}
|
||||
}
|
||||
if (errorsList.isNotEmpty) {
|
||||
return errorsList;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthState with _$OAuthState {
|
||||
const factory OAuthState() = _OAuthState;
|
||||
|
||||
const factory OAuthState.loading() = OAuthStateLoading;
|
||||
|
||||
const factory OAuthState.loaded({required String token}) = OAuthStateLoaded;
|
||||
|
||||
const factory OAuthState.error({required String errorMessage}) = OAuthStateError;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ignore_for_file: unused_element, duplicate_ignore
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
@@ -14,6 +14,10 @@ import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
@@ -46,7 +50,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
late StreamSubscription<bool> keyboardSubscription;
|
||||
bool isFocused = false;
|
||||
int tapsCount = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -89,9 +93,142 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
void _onVkPressed() async {
|
||||
try {
|
||||
log('VK button pressed');
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
log('Context is null');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Getting VK auth result');
|
||||
final authResult = await SocialAuthFacade.signInWithVkAndFetchProfile(context: context);
|
||||
if (authResult == null) {
|
||||
log('VK auth result is null');
|
||||
return;
|
||||
}
|
||||
|
||||
log('VK auth result received, trying OAuth login');
|
||||
if (context.mounted) {
|
||||
await _tryOAuthLogin(context, authResult, 'vk');
|
||||
} else {
|
||||
log('Context is not mounted');
|
||||
}
|
||||
} catch (e) {
|
||||
log('VK button error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onOkPressed() async {
|
||||
try {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
final authResult = await SocialAuthFacade.signInWithOkAndFetchProfile();
|
||||
if (authResult == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
await _tryOAuthLogin(context, authResult, 'vk');
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onMailRuPressed() async {
|
||||
try {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
final authResult = await SocialAuthFacade.signInWithMailRuAndFetchProfile();
|
||||
if (authResult == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
await _tryOAuthLogin(context, authResult, 'vk');
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onYandexPressed() async {
|
||||
try {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
final authResult = await SocialAuthFacade.signInWithYandex();
|
||||
if (authResult == null) return;
|
||||
|
||||
if (context.mounted) {
|
||||
await _tryOAuthLogin(context, authResult, 'yandex');
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tryOAuthLogin(BuildContext context, AuthResultEntity authResult, String provider) async {
|
||||
log('Starting OAuth login for provider: $provider');
|
||||
|
||||
try {
|
||||
final oauthCubit = CubitRegistrations.sl<OAuthCubit>();
|
||||
StreamSubscription? subscription;
|
||||
|
||||
subscription = oauthCubit.stream.listen((state) {
|
||||
log('OAuth state changed: ${state.runtimeType}');
|
||||
|
||||
if (state is OAuthStateLoaded) {
|
||||
log('OAuth login successful, navigating to main screen');
|
||||
subscription?.cancel();
|
||||
_reconnectAfterAuth();
|
||||
} else if (state is OAuthStateError) {
|
||||
log('OAuth login failed: ${state.errorMessage}');
|
||||
subscription?.cancel();
|
||||
AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider);
|
||||
}
|
||||
});
|
||||
|
||||
log('Calling getOAuthLogin with provider: $provider');
|
||||
await oauthCubit.signInViaSocialMedia(
|
||||
provider: provider,
|
||||
accessToken: authResult.accessToken,
|
||||
refreshToken: authResult.refreshToken ?? '',
|
||||
clientId: authResult.clientId,
|
||||
);
|
||||
} catch (e) {
|
||||
log('OAuth login error in catch block: $e');
|
||||
AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconnectAfterAuth() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final bool useMobileLayout = shortestSide < 600;
|
||||
if (!useMobileLayout) isFocused = true;
|
||||
final s = S.of(context);
|
||||
@@ -102,9 +239,10 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
builder: (BuildContext context, AuthState state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: screenSize.height * 0.15,
|
||||
centerTitle: true,
|
||||
toolbarHeight: isFocused ? 64 : MediaQuery.of(context).size.width - 64,
|
||||
title: GestureDetector(
|
||||
key: AppKeys.hiddenLogoButtonAuthScreen,
|
||||
onTap: () {
|
||||
tapsCount++;
|
||||
if (tapsCount == 7) {
|
||||
@@ -112,7 +250,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
GoRouter.of(context).pushNamed(DevToolsScreen.name);
|
||||
}
|
||||
},
|
||||
child: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: isFocused ? 64 : MediaQuery.of(context).size.width - 64, fit: BoxFit.contain),
|
||||
child: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: screenSize.height * 0.20),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -121,9 +259,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Social auth buttons at the top (outlined)
|
||||
const Text('Авторизация через:', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
const Center(child: Text('Авторизация через')),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -164,6 +301,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputType: TextInputType.emailAddress,
|
||||
key: AppKeys.emailTextFieldAuthScreen,
|
||||
hintText: 'Введите email',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -172,6 +310,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
CustomTextFormField(
|
||||
obscureText: hidePassword,
|
||||
controller: passwordController,
|
||||
key: AppKeys.passwordTextFieldAuthScreen,
|
||||
hintText: 'Введите пароль',
|
||||
suffix: IconButton(
|
||||
onPressed: () {
|
||||
@@ -224,7 +363,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
decorationColor: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
||||
),
|
||||
const TextSpan(text: ' и '),
|
||||
],
|
||||
@@ -236,7 +375,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
decorationColor: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/docs/privacy.pdf')),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}/docs/privacy.pdf')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -249,21 +388,28 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width - 88,
|
||||
child: FloatingActionButton.extended(
|
||||
key: AppKeys.signInButtonAuthScreen,
|
||||
heroTag: 'authButton',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
|
||||
onPressed: state is AuthStateLoading
|
||||
backgroundColor: (state is AuthStateLoading || _isLoading) ? Colors.black.withAlpha(50) : appTheme.blueA400,
|
||||
onPressed: (state is AuthStateLoading || _isLoading)
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
|
||||
if (isAuthenticated) {
|
||||
if (context.mounted) {
|
||||
_reconnectWebSocket(context);
|
||||
await _reconnectWebSocket(context);
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
},
|
||||
label: state is AuthStateLoading
|
||||
? const CupertinoActivityIndicator()
|
||||
@@ -296,7 +442,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
children: [
|
||||
const Text('Где взять QR-код?\n', style: TextStyle(fontSize: 18)),
|
||||
Text(
|
||||
'1. Войдите на сайте $baseUrl в свой аккаунт\n\n2. Перейдите в настройки профиля\n\n3. В разделе "Доп информация" перейдите на "QR Login"',
|
||||
'1. Войдите на сайте ${sl<AppConfig>().baseUrl} в свой аккаунт\n\n2. Перейдите в настройки профиля\n\n3. В разделе "Доп информация" перейдите на "QR Login"',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
@@ -388,58 +534,3 @@ class _SocialButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onVkPressed() async {
|
||||
try {
|
||||
final accessToken = await SocialAuthFacade.signInWithVk();
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onOkPressed() async {
|
||||
try {
|
||||
final accessToken = await SocialAuthFacade.signInWithOk();
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onMailRuPressed() async {
|
||||
try {
|
||||
final accessToken = await SocialAuthFacade.signInWithMailRu();
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void _onYandexPressed() async {
|
||||
final accessToken = await SocialAuthFacade.signInWithYandex();
|
||||
if (accessToken == null) return;
|
||||
}
|
||||
|
||||
Future<void> _reconnectAfterAuth() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (_) {}
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RegisterViaSocialScreen extends StatefulWidget {
|
||||
final AuthResultEntity? authResult;
|
||||
final String? provider;
|
||||
|
||||
const RegisterViaSocialScreen({super.key, this.authResult, this.provider});
|
||||
|
||||
@override
|
||||
State<RegisterViaSocialScreen> createState() => _RegisterViaSocialScreenState();
|
||||
}
|
||||
|
||||
class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
final maskFormatter = MaskTextInputFormatter(mask: '+7 (###) ### ## ##', filter: {"#": RegExp(r'[0-9]')}, type: MaskAutoCompletionType.lazy);
|
||||
final maskFormatterForBirthday = MaskTextInputFormatter(mask: '##.##.####', filter: {"#": RegExp(r'[0-9]')}, type: MaskAutoCompletionType.lazy);
|
||||
|
||||
// Контроллеры для полей ввода
|
||||
final firstNameController = TextEditingController();
|
||||
final lastNameController = TextEditingController();
|
||||
final emailController = TextEditingController();
|
||||
final phoneController = TextEditingController();
|
||||
final dateOfBirthController = TextEditingController();
|
||||
final weightController = TextEditingController();
|
||||
final heightController = TextEditingController();
|
||||
bool emailExistError = false;
|
||||
bool phoneExistError = false;
|
||||
bool dateOfBirthError = false;
|
||||
bool weightError = false;
|
||||
bool heightError = false;
|
||||
bool incorrectEmailFormatError = false;
|
||||
|
||||
// Состояние для выбора пола
|
||||
bool isMaleSelected = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeFieldsFromAuthResult();
|
||||
}
|
||||
|
||||
Future<void> _reconnectWebSocket(BuildContext context) async {
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
if (context.mounted) {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешная регистрация');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
void _initializeFieldsFromAuthResult() {
|
||||
if (widget.authResult != null) {
|
||||
final authResult = widget.authResult!;
|
||||
|
||||
if (authResult.firstName != null) {
|
||||
firstNameController.text = authResult.firstName!;
|
||||
}
|
||||
if (authResult.lastName != null) {
|
||||
lastNameController.text = authResult.lastName!;
|
||||
}
|
||||
if (authResult.email != null) {
|
||||
emailController.text = authResult.email!;
|
||||
}
|
||||
if (authResult.phone != null) {
|
||||
final cleanPhone = authResult.phone!.replaceAll(RegExp(r'[^\d]'), '');
|
||||
final normalizedPhone = cleanPhone.startsWith('8') ? '7${cleanPhone.substring(1)}' : cleanPhone;
|
||||
final phoneWithCountryCode = normalizedPhone.startsWith('7') ? normalizedPhone : '7$normalizedPhone';
|
||||
final formattedPhone = maskFormatter.maskText('+$phoneWithCountryCode');
|
||||
phoneController.text = formattedPhone;
|
||||
}
|
||||
if (authResult.birthDate != null) {
|
||||
final formattedDate = _formatBirthDate(authResult.birthDate!);
|
||||
final maskedDate = maskFormatterForBirthday.maskText(formattedDate);
|
||||
dateOfBirthController.text = maskedDate;
|
||||
}
|
||||
if (authResult.gender != null) {
|
||||
isMaleSelected = authResult.gender == GenderEnum.male;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBirthDate(String birthDate) {
|
||||
try {
|
||||
// Пытаемся распарсить различные форматы даты
|
||||
DateTime? date;
|
||||
|
||||
// Если дата в формате ISO (YYYY-MM-DD)
|
||||
if (birthDate.contains('-')) {
|
||||
date = DateTime.tryParse(birthDate);
|
||||
}
|
||||
// Если дата в формате DD.MM.YYYY
|
||||
else if (birthDate.contains('.')) {
|
||||
final parts = birthDate.split('.');
|
||||
if (parts.length == 3) {
|
||||
final day = int.tryParse(parts[0]);
|
||||
final month = int.tryParse(parts[1]);
|
||||
final year = int.tryParse(parts[2]);
|
||||
if (day != null && month != null && year != null) {
|
||||
date = DateTime(year, month, day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (date != null) {
|
||||
// Форматируем в DD.MM.YYYY
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final year = date.year.toString();
|
||||
return '$day.$month.$year';
|
||||
}
|
||||
} catch (e) {
|
||||
// Если не удалось распарсить, возвращаем исходную строку
|
||||
}
|
||||
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
firstNameController.dispose();
|
||||
lastNameController.dispose();
|
||||
emailController.dispose();
|
||||
phoneController.dispose();
|
||||
dateOfBirthController.dispose();
|
||||
weightController.dispose();
|
||||
heightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<OAuthCubit>(
|
||||
create: (context) => CubitRegistrations.sl<OAuthCubit>(),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: BlocBuilder<OAuthCubit, OAuthState>(
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Center(
|
||||
child: Text(
|
||||
'Регистрация',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1, color: Color(0xFF323F4B)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Имя'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: firstNameController, hintText: 'Введите имя', onChanged: (newValue) {}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Фамилия'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: lastNameController, hintText: 'Введите фамилию', onChanged: (newValue) {}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Почта'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: emailController,
|
||||
textInputType: TextInputType.emailAddress,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
hintText: 'Введите email',
|
||||
textInputAction: TextInputAction.next,
|
||||
hasError: emailExistError || incorrectEmailFormatError,
|
||||
onChanged: (newValue) {},
|
||||
),
|
||||
if (emailExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такая почта уже существует.')],
|
||||
if (incorrectEmailFormatError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Некорректный формат email')],
|
||||
const SizedBox(height: 12),
|
||||
const Text('Номер телефона'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
textInputAction: TextInputAction.done,
|
||||
hintText: '+7 (999) 999 99 99',
|
||||
controller: phoneController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [maskFormatter],
|
||||
hasError: phoneExistError,
|
||||
onChanged: (newValue) {},
|
||||
),
|
||||
if (phoneExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такой номер телефона уже существует.')],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Дата рождения'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
textInputAction: TextInputAction.done,
|
||||
hintText: '01.01.2000',
|
||||
controller: dateOfBirthController,
|
||||
keyboardType: TextInputType.number,
|
||||
hasError: dateOfBirthError,
|
||||
inputFormatters: [maskFormatterForBirthday],
|
||||
onChanged: (newValue) {},
|
||||
),
|
||||
if (dateOfBirthError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Некорректный формат даты рождения')],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Пол'),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _genderButton('М', isMaleSelected)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: _genderButton('Ж', !isMaleSelected)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Вес'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}, hasError: weightError),
|
||||
if (weightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Вес должен быть от 30 до 300 кг')],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Рост'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}, hasError: heightError),
|
||||
if (heightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Рост должен быть от 100 до 250 см')],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Проверьте правильность заполненных\nданных. Пол, рост и вес требуются для\nрасчета вашей дневной нормы калорий',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _handleRegistration(context),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(color: state is OAuthStateLoading ? Colors.grey : appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
child: Center(
|
||||
child: state is OAuthStateLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text(
|
||||
'Зарегистрироваться',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: 'Нажимая "Зарегистрироваться", Вы соглашаетесь\n',
|
||||
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: 'c ',
|
||||
style: TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
if (!hidePlans) ...[
|
||||
TextSpan(
|
||||
text: ' Публичной офертой',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: appTheme.blueA400,
|
||||
decorationColor: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}oferta_bs.pdf')),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' и ',
|
||||
style: TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
],
|
||||
TextSpan(
|
||||
text: 'Политикой конфиденциальности и защитой персональных данных',
|
||||
style: TextStyle(fontSize: 16, color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline),
|
||||
recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('${sl<AppConfig>().baseUrl}/docs/privacy.pdf')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Обработка успешной регистрации
|
||||
if (state is OAuthStateLoaded) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
),
|
||||
child: const Text(
|
||||
'Регистрация успешно завершена!',
|
||||
style: TextStyle(color: Colors.green),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// Автоматический переход на главный экран после успешной регистрации
|
||||
Builder(
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_reconnectWebSocket(context);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _genderButton(String gender, bool isActive) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
isMaleSelected = gender == 'М';
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isActive ? Border.all(width: 2, color: appTheme.blueA400) : null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
gender,
|
||||
style: TextStyle(color: isActive ? appTheme.blueA400 : const Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleRegistration(BuildContext context) async {
|
||||
final clientID = widget.authResult?.clientId;
|
||||
// Валидация полей
|
||||
if (firstNameController.text.isEmpty ||
|
||||
lastNameController.text.isEmpty ||
|
||||
emailController.text.isEmpty ||
|
||||
phoneController.text.isEmpty ||
|
||||
dateOfBirthController.text.isEmpty ||
|
||||
weightController.text.isEmpty ||
|
||||
heightController.text.isEmpty) {
|
||||
showErrorToast('Пожалуйста, заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
// Парсим числовые значения
|
||||
int? weight;
|
||||
int? height;
|
||||
|
||||
weight = int.tryParse(weightController.text);
|
||||
height = int.tryParse(heightController.text);
|
||||
bool hasErrorWeightOrHeightError = false;
|
||||
if (weight == null || height == null) {
|
||||
setState(() {
|
||||
weightError = true;
|
||||
heightError = true;
|
||||
});
|
||||
hasErrorWeightOrHeightError = true;
|
||||
}
|
||||
if (weight! < 30 || weight > 300) {
|
||||
setState(() {
|
||||
weightError = true;
|
||||
});
|
||||
hasErrorWeightOrHeightError = true;
|
||||
}
|
||||
if (height! < 100 || height > 250) {
|
||||
setState(() {
|
||||
heightError = true;
|
||||
});
|
||||
hasErrorWeightOrHeightError = true;
|
||||
}
|
||||
|
||||
if (hasErrorWeightOrHeightError) return;
|
||||
|
||||
// Очищаем номер телефона от маски для отправки
|
||||
final cleanPhone = phoneController.text.replaceAll(RegExp(r'[^\d+]'), '');
|
||||
|
||||
if (clientID == null) return;
|
||||
// Вызываем OAuth регистрацию
|
||||
final errors = await context.read<OAuthCubit>().signUpViaSocialMedia(
|
||||
provider: widget.provider ?? 'social',
|
||||
accessToken: widget.authResult?.accessToken ?? 'dummy_token',
|
||||
id: widget.authResult?.userId ?? 'dummy_id',
|
||||
refreshToken: widget.authResult?.refreshToken ?? 'dummy_refresh_token',
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
email: emailController.text,
|
||||
phone: cleanPhone,
|
||||
dateOfBirth: dateOfBirthController.text,
|
||||
gender: isMaleSelected ? 'М' : 'Ж',
|
||||
weight: weight,
|
||||
height: height,
|
||||
clientId: clientID,
|
||||
);
|
||||
if (errors != null) {
|
||||
for (final error in errors) {
|
||||
switch (error) {
|
||||
case RegistrationValidationErrors.incorrectEmailFormat:
|
||||
setState(() {
|
||||
incorrectEmailFormatError = true;
|
||||
});
|
||||
break;
|
||||
case RegistrationValidationErrors.emailExist:
|
||||
setState(() {
|
||||
emailExistError = true;
|
||||
});
|
||||
break;
|
||||
case RegistrationValidationErrors.phoneExist:
|
||||
setState(() {
|
||||
phoneExistError = true;
|
||||
});
|
||||
break;
|
||||
case RegistrationValidationErrors.incorrectDateOfBirthFormat:
|
||||
setState(() {
|
||||
dateOfBirthError = true;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
showSuccessToast('Регистрация успешна');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
@@ -40,17 +40,11 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Сканирование QR-кода'),
|
||||
centerTitle: true,
|
||||
),
|
||||
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: QRView(
|
||||
key: qrKey,
|
||||
onQRViewCreated: _onQRViewCreated,
|
||||
),
|
||||
child: QRView(key: qrKey, onQRViewCreated: _onQRViewCreated),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -68,17 +62,9 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
final scannedUrl = scanData.code;
|
||||
final uri = Uri.parse(scannedUrl ?? '');
|
||||
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
|
||||
baseUrl = scannedBaseUrl;
|
||||
sl<Dio>().options.baseUrl = baseUrl;
|
||||
final response = await Dio().getUri(
|
||||
uri,
|
||||
options: Options(
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
sl<AppConfig>().setBaseUrl(scannedBaseUrl);
|
||||
sl<Dio>().options.baseUrl = scannedBaseUrl;
|
||||
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'}));
|
||||
final token = response.data['token'];
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
try {
|
||||
@@ -97,10 +83,7 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.go(
|
||||
'/feed-screen',
|
||||
extra: {'needToInitializeWebSocket': false},
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
showSuccessToast('Добро пожаловать!');
|
||||
} catch (e) {
|
||||
await controller.resumeCamera();
|
||||
|
||||
@@ -1,39 +1,76 @@
|
||||
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/token_model.dart';
|
||||
import 'package:eatvio_app/features/auth/services/auth_service.dart';
|
||||
import 'package:eatvio_app/features/auth/models/social_exchange_request.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final AuthService authService;
|
||||
|
||||
AuthRepository({required this.authService});
|
||||
|
||||
Future<TokenModel> getTokenByCredentials(
|
||||
String email,
|
||||
String password,
|
||||
) async {
|
||||
Future<TokenModel> getTokenByCredentials(String email, String password) async {
|
||||
try {
|
||||
return await authService.getTokenByCredentials(
|
||||
credentials: CredentialsModel(
|
||||
email: email,
|
||||
password: password,
|
||||
),
|
||||
credentials: CredentialsModel(email: email, password: password),
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<TokenModel> getTokenBySocial({
|
||||
|
||||
Future<TokenModel> signInViaSocialMedia({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required String clientId,
|
||||
}) async {
|
||||
try {
|
||||
return await authService.getTokenBySocial(
|
||||
body: SocialExchangeRequest(provider: provider, accessToken: accessToken),
|
||||
return await authService.signInViaSocialMedia(
|
||||
body: OAuthLoginRequest(provider: provider, accessToken: accessToken, refreshToken: refreshToken, clientId: clientId),
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> signUpViaSocialMedia({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String id,
|
||||
required String refreshToken,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String email,
|
||||
required String phone,
|
||||
required String dateOfBirth,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
required String clientId,
|
||||
}) async {
|
||||
try {
|
||||
return (await authService.signUpViaSocialMedia(
|
||||
body: OAuthModel(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
id: id,
|
||||
refreshToken: refreshToken,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
email: email,
|
||||
phone: phone,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender == 'М' ? 'male' : 'female',
|
||||
weight: weight,
|
||||
height: height,
|
||||
clientId: clientId,
|
||||
),
|
||||
))['token']!
|
||||
as String;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:vk_id/vk_id.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
@@ -20,14 +22,14 @@ class SocialAuthFacade {
|
||||
}
|
||||
|
||||
static Future<AuthResultEntity?> signInWithVkAndFetchProfile({required BuildContext context}) async {
|
||||
final clientIdStr = EnvConfig.vkClientId.trim();
|
||||
final clientIdStr = Platform.isIOS ? EnvConfig.vkClientIdIos.trim() : EnvConfig.vkClientIdAndroid.trim();
|
||||
if (clientIdStr.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final controller = VkIDController(clID: int.parse(clientIdStr));
|
||||
final authUri = controller.generateAuthorizeLinkWithCodeVerifier();
|
||||
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: VkProvider.vkID, scopes: [VkScope.email, VkScope.phone]);
|
||||
|
||||
final authUrl = authUri.value.toString();
|
||||
final codeVerifier = authUri.key;
|
||||
@@ -37,7 +39,7 @@ class SocialAuthFacade {
|
||||
if (redirectParams == null) {
|
||||
return null;
|
||||
}
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier);
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier, clientId: clientIdStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@@ -47,6 +49,7 @@ class SocialAuthFacade {
|
||||
required Map<String, String> redirectParams,
|
||||
required VkIDController controller,
|
||||
required String codeVerifier,
|
||||
required String clientId,
|
||||
}) async {
|
||||
final code = redirectParams['code'];
|
||||
final deviceId = redirectParams['device_id'];
|
||||
@@ -87,36 +90,20 @@ class SocialAuthFacade {
|
||||
phone: profile.phone,
|
||||
gender: GenderEnum.fromVkGender(profile.parsedSex),
|
||||
birthDate: profile.bDayDt?.toString(),
|
||||
clientId: clientId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> exchangeCodeForToken(String code, String clientId, String clientSecret) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('https://oauth.yandex.ru/token'),
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: {'grant_type': 'authorization_code', 'code': code, 'client_id': clientId, 'client_secret': clientSecret},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
} else {
|
||||
log('Error exchanging code: ${response.body}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> signInWithOk() async {
|
||||
static Future<AuthResultEntity?> signInWithOkAndFetchProfile() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return null;
|
||||
final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu);
|
||||
return vkAuthRes?.accessToken;
|
||||
return await _signInWithVkIdProvider(context: context, provider: VkProvider.okRu);
|
||||
}
|
||||
|
||||
static Future<String?> signInWithMailRu() async {
|
||||
static Future<AuthResultEntity?> signInWithMailRuAndFetchProfile() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return null;
|
||||
final vkAuthRes = await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu);
|
||||
return vkAuthRes?.accessToken;
|
||||
return await _signInWithVkIdProvider(context: context, provider: VkProvider.mailRu);
|
||||
}
|
||||
|
||||
static Future<AuthResultEntity?> signInWithYandex() async {
|
||||
@@ -135,7 +122,7 @@ class SocialAuthFacade {
|
||||
'response_type': 'code',
|
||||
'client_id': clientId,
|
||||
'redirect_uri': redirectUri,
|
||||
'force_confirm': 'yes',
|
||||
'force_confirm': 'true',
|
||||
}).toString();
|
||||
|
||||
final redirectSchemePrefix = redirectUri;
|
||||
@@ -167,6 +154,7 @@ class SocialAuthFacade {
|
||||
phone: yandexProfileData.defaultPhone.number,
|
||||
gender: GenderEnum.fromString(yandexProfileData.sex),
|
||||
birthDate: yandexProfileData.birthday,
|
||||
clientId: clientId,
|
||||
);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
@@ -197,14 +185,14 @@ class SocialAuthFacade {
|
||||
}
|
||||
|
||||
static Future<AuthResultEntity?> _signInWithVkIdProvider({required BuildContext context, required VkProvider provider}) async {
|
||||
final clientIdStr = EnvConfig.vkClientId.trim();
|
||||
final clientIdStr = Platform.isIOS ? EnvConfig.vkClientIdIos.trim() : EnvConfig.vkClientIdAndroid.trim();
|
||||
if (clientIdStr.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final controller = VkIDController(clID: int.parse(clientIdStr));
|
||||
|
||||
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider);
|
||||
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider, scopes: [VkScope.phone, VkScope.email]);
|
||||
final codeVerifier = authUri.key;
|
||||
final authUrl = authUri.value.toString();
|
||||
final redirectScheme = 'vk$clientIdStr://';
|
||||
@@ -215,7 +203,7 @@ class SocialAuthFacade {
|
||||
final deviceId = redirectParams['device_id'];
|
||||
if (code == null || deviceId == null) return null;
|
||||
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier);
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier, clientId: clientIdStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
@@ -246,7 +234,9 @@ class SocialAuthFacade {
|
||||
}
|
||||
}
|
||||
}
|
||||
Navigator.of(context, rootNavigator: true).pop(params);
|
||||
if (GoRouter.of(context).canPop()) {
|
||||
GoRouter.of(context).pop(params);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/token_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/social_exchange_request.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
part 'auth_service.g.dart';
|
||||
@@ -11,12 +12,11 @@ abstract class AuthService {
|
||||
factory AuthService(Dio dio, {String? baseUrl}) = _AuthService;
|
||||
|
||||
@POST('/api/login')
|
||||
Future<TokenModel> getTokenByCredentials({
|
||||
@Body() required CredentialsModel credentials,
|
||||
});
|
||||
Future<TokenModel> getTokenByCredentials({@Body() required CredentialsModel credentials});
|
||||
|
||||
@POST('/api/social/login')
|
||||
Future<TokenModel> getTokenBySocial({
|
||||
@Body() required SocialExchangeRequest body,
|
||||
});
|
||||
@POST('/api/oauth/callback')
|
||||
Future<Map<String, Object?>> signUpViaSocialMedia({@Body() required OAuthModel body});
|
||||
|
||||
@POST('/api/oauth/login')
|
||||
Future<TokenModel> signInViaSocialMedia({@Body() required OAuthLoginRequest body});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ class BlogPostCubit extends Cubit<BlogPostState> {
|
||||
Future<void> getBlogPost(int id) async {
|
||||
try {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final postModel = await feedRepository.getBlogPost(id: id);
|
||||
|
||||
emit(state.copyWith(blogPostModel: postModel, isLoading: false));
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
@@ -18,6 +18,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
@@ -198,7 +199,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author),
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -207,7 +208,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -231,15 +232,40 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Html(
|
||||
data: blogPostState.blogPostModel?.description.replaceAll('\n', '<br>'),
|
||||
style: {"*": Style(color: Colors.black87)},
|
||||
data: md.markdownToHtml(blogPostState.blogPostModel!.description.replaceAll('\n', '<br>')),
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
||||
CachedNetworkImage(imageUrl: '$baseUrl/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
@@ -267,7 +293,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('$baseUrl/ru/blog_post/${widget.id}'),
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ class BlogPostCreateCubit extends BaseCubit<BlogPostCreateState> {
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
handleError(error, stackTrace);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class BlogPostCreateCubit extends BaseCubit<BlogPostCreateState> {
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
handleError(error, stackTrace);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/app_decoration.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
@@ -24,10 +25,7 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
class BlogPostCreateScreen extends StatefulWidget {
|
||||
final FeedBlogPostModel? feedBlogPostModel;
|
||||
|
||||
const BlogPostCreateScreen({
|
||||
super.key,
|
||||
this.feedBlogPostModel,
|
||||
});
|
||||
const BlogPostCreateScreen({super.key, this.feedBlogPostModel});
|
||||
|
||||
@override
|
||||
State<BlogPostCreateScreen> createState() => _BlogPostCreateScreenState();
|
||||
@@ -42,6 +40,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
final descriptionController = TextEditingController();
|
||||
final videoUrlController = TextEditingController();
|
||||
bool isLoading = false;
|
||||
bool enableMdPanel = false;
|
||||
bool isPreviewMode = false;
|
||||
final FocusNode descriptionFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -50,6 +51,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
|
||||
setState(() {
|
||||
shrinkImage = visible;
|
||||
enableMdPanel = visible;
|
||||
});
|
||||
});
|
||||
if (widget.feedBlogPostModel != null) {
|
||||
@@ -62,9 +64,326 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
keyboardSubscription.cancel();
|
||||
descriptionFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _hasFormatting(String prefix, String suffix) {
|
||||
final text = descriptionController.text;
|
||||
final selection = descriptionController.selection;
|
||||
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
||||
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
||||
return beforeCursor == prefix && afterCursor == suffix;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _removeFormatting(String prefix, String suffix) {
|
||||
final text = descriptionController.text;
|
||||
final selection = descriptionController.selection;
|
||||
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
||||
|
||||
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||
final newText = beforeCursor + afterCursor;
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition - prefix.length;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _applyMarkdownFormat(String prefix, String suffix) {
|
||||
final text = descriptionController.text;
|
||||
final selection = descriptionController.selection;
|
||||
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final formattedText = '$prefix$selectedText$suffix';
|
||||
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition + prefix.length;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyHeading() {
|
||||
if (_hasFormatting('# ', '')) {
|
||||
_removeFormatting('# ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('# ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBold() {
|
||||
if (_hasFormatting('**', '**')) {
|
||||
_removeFormatting('**', '**');
|
||||
} else {
|
||||
_applyMarkdownFormat('**', '**');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyItalic() {
|
||||
if (_hasFormatting('*', '*')) {
|
||||
_removeFormatting('*', '*');
|
||||
} else {
|
||||
_applyMarkdownFormat('*', '*');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyNumberedList() {
|
||||
if (_hasFormatting('1. ', '')) {
|
||||
_removeFormatting('1. ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('1. ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyBulletedList() {
|
||||
if (_hasFormatting('- ', '')) {
|
||||
_removeFormatting('- ', '');
|
||||
} else {
|
||||
_applyMarkdownFormat('- ', '');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyLink() {
|
||||
final text = descriptionController.text;
|
||||
final selection = descriptionController.selection;
|
||||
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
|
||||
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
|
||||
if (linkRegex.hasMatch(selectedText)) {
|
||||
final match = linkRegex.firstMatch(selectedText);
|
||||
final linkText = match?.group(1) ?? selectedText;
|
||||
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final formattedText = '[$selectedText](url)';
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
||||
descriptionController.selection = newSelection;
|
||||
}
|
||||
} else {
|
||||
_applyMarkdownFormat('[', '](url)');
|
||||
}
|
||||
}
|
||||
|
||||
void _applyHorizontalRule() {
|
||||
final cursorPosition = descriptionController.selection.baseOffset;
|
||||
final text = descriptionController.text;
|
||||
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition + 5;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
}
|
||||
|
||||
void _togglePreview() {
|
||||
setState(() {
|
||||
isPreviewMode = !isPreviewMode;
|
||||
});
|
||||
|
||||
if (!isPreviewMode) {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
descriptionFocusNode.requestFocus();
|
||||
});
|
||||
} else {
|
||||
descriptionFocusNode.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMarkdownPreview() {
|
||||
return Container(
|
||||
height: 120,
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _parseMarkdown(String text) {
|
||||
if (text.isEmpty) {
|
||||
return const Text(
|
||||
'Описание',
|
||||
style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500),
|
||||
);
|
||||
}
|
||||
|
||||
final lines = text.split('\n');
|
||||
final widgets = <Widget>[];
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
|
||||
if (line.trim().isEmpty) {
|
||||
widgets.add(const SizedBox(height: 8));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(2),
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(3),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(4),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(fontSize: 16)),
|
||||
Expanded(child: _parseInlineMarkdown(line.substring(2))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
|
||||
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
|
||||
final number = match?.group(1) ?? '1';
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
||||
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (line.trim() == '---') {
|
||||
widgets.add(const Divider(color: Colors.grey, thickness: 1));
|
||||
} else {
|
||||
widgets.add(_parseInlineMarkdown(line));
|
||||
}
|
||||
|
||||
if (i < lines.length - 1) {
|
||||
widgets.add(const SizedBox(height: 4));
|
||||
}
|
||||
}
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
||||
}
|
||||
|
||||
Widget _parseInlineMarkdown(String text) {
|
||||
final spans = <TextSpan>[];
|
||||
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
|
||||
int lastEnd = 0;
|
||||
|
||||
for (final match in regex.allMatches(text)) {
|
||||
if (match.start > lastEnd) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(lastEnd, match.start),
|
||||
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final matchedText = match.group(0)!;
|
||||
|
||||
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(2, matchedText.length - 2),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(1, matchedText.length - 1),
|
||||
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
|
||||
if (linkMatch != null) {
|
||||
final linkText = linkMatch.group(1)!;
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: linkText,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = match.end;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(lastEnd),
|
||||
style: const TextStyle(fontSize: 16, color: Colors.black),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RichText(text: TextSpan(children: spans));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<BlogPostCreateCubit>(
|
||||
@@ -102,13 +421,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -126,96 +439,102 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mdButton(IconData icon, Function() onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
child: Icon(icon),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mdTextButton(String text, Function() onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'createBlogPost',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: appTheme.blueA400,
|
||||
label: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text(
|
||||
'Создать',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
floatingActionButton: enableMdPanel || isPreviewMode
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||
),
|
||||
child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||
),
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
if (widget.feedBlogPostModel != null) {
|
||||
final result = await blogPostCubit.updateBlogPost(
|
||||
id: widget.feedBlogPostModel!.id,
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
oldImageUrl: widget.feedBlogPostModel!.image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_update_blog_post);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
final result = await blogPostCubit.createBlogPost(
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
appBar: AppBar(),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -224,17 +543,19 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
controller: titleController,
|
||||
),
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
controller: descriptionController,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (videoUrlController.text.isEmpty) const Text('Изображение поста'),
|
||||
if (videoUrlController.text.isEmpty) const SizedBox(height: 8),
|
||||
@@ -243,16 +564,75 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'),
|
||||
if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8),
|
||||
if (image == null && widget.feedBlogPostModel?.image == null)
|
||||
CustomTextFormField(
|
||||
controller: videoUrlController,
|
||||
),
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createBlogPostButton() {
|
||||
return InkWell(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
if (widget.feedBlogPostModel != null) {
|
||||
final result = await blogPostCubit.updateBlogPost(
|
||||
id: widget.feedBlogPostModel!.id,
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
oldImageUrl: widget.feedBlogPostModel!.image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_update_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
final result = await blogPostCubit.createBlogPost(
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: const Center(
|
||||
child: Text('Создать', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GestureDetector _buildAddPhoto() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -265,22 +645,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
height: shrinkImage ? 52 : 128,
|
||||
curve: Curves.easeIn,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: AppDecoration.fillGray.copyWith(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library,
|
||||
color: appTheme.blueGray300,
|
||||
),
|
||||
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Добавить фото",
|
||||
selectionColor: appTheme.blueA400,
|
||||
),
|
||||
Text("Добавить фото", selectionColor: appTheme.blueA400),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -289,13 +661,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image(
|
||||
image: FileImage(
|
||||
File(
|
||||
image!.path,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Image(image: FileImage(File(image!.path))),
|
||||
),
|
||||
),
|
||||
if (widget.feedBlogPostModel?.image != null && image == null)
|
||||
@@ -303,9 +669,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image(
|
||||
image: NetworkImage('$baseUrl/storage/${widget.feedBlogPostModel!.image}'),
|
||||
),
|
||||
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||
@@ -36,7 +37,7 @@ class BlogPostRepository {
|
||||
Future<XFile?> downloadImage(String imageUrl) async {
|
||||
try {
|
||||
// Загрузка изображения
|
||||
final response = await http.get(Uri.parse('$baseUrl/storage/$imageUrl'));
|
||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Получение временной директории
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
@@ -102,7 +103,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
widget.profileModel.profilePhotoUrl.contains('https') ? widget.profileModel.profilePhotoUrl : baseUrl + widget.profileModel.profilePhotoUrl,
|
||||
widget.profileModel.profilePhotoUrl.contains('https') ? widget.profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + widget.profileModel.profilePhotoUrl,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
@@ -115,25 +114,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
ChosenPlanStateEnum.statePendingPayment,
|
||||
ChosenPlanStateEnum.stateInCoordination,
|
||||
].contains(chosenPlan?.chosenPlanState);
|
||||
|
||||
final showAboutOrderButton =
|
||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
BannerType? bannerType;
|
||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
bannerType = null;
|
||||
} else if (amIConsultant && !state.hasCard) {
|
||||
bannerType = BannerType.card;
|
||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.date;
|
||||
} else if (!amIConsultant &&
|
||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
||||
bannerType = BannerType.payment;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
||||
bannerType = BannerType.collectCharge;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
||||
bannerType = BannerType.finishPlan;
|
||||
}
|
||||
|
||||
PacketOptions? chosenOption;
|
||||
if (chosenPlan?.optionType == 'options1') {
|
||||
chosenOption = chosenPlan?.plan.packet1Options;
|
||||
@@ -142,6 +122,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
} else if (chosenPlan?.optionType == 'options3') {
|
||||
chosenOption = chosenPlan?.plan.packet3Options;
|
||||
}
|
||||
|
||||
final showAboutOrderButton =
|
||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
BannerType? bannerType;
|
||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
bannerType = null;
|
||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.card;
|
||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.date;
|
||||
} else if (!amIConsultant &&
|
||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
||||
bannerType = BannerType.payment;
|
||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant) {
|
||||
bannerType = BannerType.setPersonalDiet;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
||||
bannerType = BannerType.collectCharge;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
||||
bannerType = BannerType.finishPlan;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
@@ -150,15 +151,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
centerTitle: false,
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: Author(
|
||||
authorSlug: state.chatRoom.users.receiver.slug,
|
||||
authorName: state.chatRoom.users.receiver.name,
|
||||
authorProfileIcon: state.chatRoom.users.receiver.profilePhotoUrl ?? '',
|
||||
authorIsMe: false,
|
||||
),
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -326,6 +319,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -453,7 +447,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final blockedUsersCubit = sl<BlockedUsersCubit>();
|
||||
await blockedUsersCubit.getBlockedUsers();
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
||||
if (isBlocked) {
|
||||
@@ -468,7 +462,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
||||
chatRoomCubit.sendMessage(
|
||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
||||
@@ -503,6 +497,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
left: 16,
|
||||
child: AboutOrderButton(
|
||||
onTap: () async {
|
||||
if (amIConsultant) {
|
||||
GoRouter.of(
|
||||
context,
|
||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -16,12 +16,9 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
|
||||
|
||||
class EmptyChatScreen extends StatefulWidget {
|
||||
final Author author;
|
||||
final ProfileModel user;
|
||||
|
||||
const EmptyChatScreen({
|
||||
super.key,
|
||||
required this.author,
|
||||
});
|
||||
const EmptyChatScreen({super.key, required this.user});
|
||||
|
||||
@override
|
||||
State<EmptyChatScreen> createState() => _EmptyChatScreenState();
|
||||
@@ -40,34 +37,19 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
centerTitle: false,
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: Author(
|
||||
authorSlug: widget.author.authorSlug,
|
||||
authorName: widget.author.authorName,
|
||||
authorProfileIcon: widget.author.authorProfileIcon,
|
||||
authorIsMe: false,
|
||||
),
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.user.slug);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
foregroundImage: NetworkImage(widget.author.authorProfileIcon),
|
||||
foregroundImage: NetworkImage(widget.user.profilePhotoUrl),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.author.authorName,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||
color: PrimaryColors().black,
|
||||
),
|
||||
),
|
||||
],
|
||||
children: [Text(widget.user.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black))],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -84,18 +66,14 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => showImagePicker(widget.author.authorSlug),
|
||||
onTap: () => showImagePicker(widget.user.slug),
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Transform.rotate(
|
||||
angle: pi / 4,
|
||||
child: const Icon(
|
||||
Icons.attach_file_rounded,
|
||||
size: 30,
|
||||
color: Color(0xFF858E99),
|
||||
),
|
||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -117,8 +95,10 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border:
|
||||
OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1)),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
@@ -139,40 +119,29 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
final blockedUsersCubit = sl<BlockedUsersCubit>();
|
||||
await blockedUsersCubit.getBlockedUsers();
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug);
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.user.slug);
|
||||
if (isBlocked) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
const SnackBar(content: Text('Вы не можете отправить сообщение заблокированному пользователю'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (textEditingController.text.isNotEmpty) {
|
||||
await sl<ChatRepository>().sendMessage(
|
||||
data: textEditingController.text.trim(),
|
||||
slug: widget.author.authorSlug,
|
||||
);
|
||||
await sl<ChatRepository>().sendMessage(data: textEditingController.text.trim(), slug: widget.user.slug);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
});
|
||||
}
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (context.mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
|
||||
'chatScreen',
|
||||
extra: {
|
||||
'uuid': chatIdOrError,
|
||||
},
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -201,53 +170,36 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
|
||||
// Проверяем, заблокирован ли пользователь
|
||||
final blockedUsersCubit = sl<BlockedUsersCubit>();
|
||||
await blockedUsersCubit.getBlockedUsers();
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug);
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.user.slug);
|
||||
if (isBlocked) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
const SnackBar(content: Text('Вы не можете отправить сообщение заблокированному пользователю'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final picker = ImagePicker();
|
||||
List<XFile>? images = await picker.pickMultiImage();
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(
|
||||
files: files,
|
||||
slug: widget.author.authorSlug,
|
||||
);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
|
||||
'chatScreen',
|
||||
extra: {
|
||||
'uuid': chatIdOrError,
|
||||
},
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_library_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -256,28 +208,14 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
List<XFile>? images = await picker.pickMultiImage();
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(
|
||||
files: files,
|
||||
slug: widget.author.authorSlug,
|
||||
);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
|
||||
'chatScreen',
|
||||
extra: {
|
||||
'uuid': chatIdOrError,
|
||||
},
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -308,35 +246,17 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
);
|
||||
if (result != null) {
|
||||
List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
await sl<ChatRepository>().sendMessage(
|
||||
files: files,
|
||||
slug: widget.author.authorSlug,
|
||||
);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
|
||||
'chatScreen',
|
||||
extra: {
|
||||
'uuid': chatIdOrError,
|
||||
},
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.file_present_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Файлы'),
|
||||
],
|
||||
),
|
||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/app_decoration.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -88,7 +89,7 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
title: Text(
|
||||
widget.imageUrls[index].replaceAll('$baseUrl/storage/chat_files/', ''),
|
||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -9,17 +9,28 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class ChosePlanDateWidget extends StatefulWidget {
|
||||
const ChosePlanDateWidget({super.key, required this.chatCubit});
|
||||
const ChosePlanDateWidget({super.key, required this.chatCubit, required this.hasPersonalDiet});
|
||||
final ChatRoomCubit chatCubit;
|
||||
|
||||
final bool hasPersonalDiet;
|
||||
@override
|
||||
State<ChosePlanDateWidget> createState() => _ChosePlanDateWidget();
|
||||
}
|
||||
|
||||
class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
|
||||
final startDate = [DateTime.now()];
|
||||
late final List<DateTime> startDate;
|
||||
bool visible = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.hasPersonalDiet) {
|
||||
startDate = [DateTime.now().add(const Duration(days: 2))];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -27,64 +38,129 @@ class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
|
||||
if (widget.chatCubit.state is ChatRoomStateLoaded) {
|
||||
planName = (widget.chatCubit.state as ChatRoomStateLoaded).chatRoom.chosenPlan?.plan.name;
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
planName ?? '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
return GestureDetector(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
planName ?? '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
if (widget.hasPersonalDiet) ...[
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
PortalTarget(
|
||||
closeDuration: const Duration(milliseconds: 20),
|
||||
anchor: const Aligned(
|
||||
shiftToWithinBound: AxisFlag(x: true, y: true),
|
||||
follower: Alignment.topRight,
|
||||
target: Alignment.bottomLeft,
|
||||
),
|
||||
portalFollower: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||
boxShadow: visible
|
||||
? [
|
||||
BoxShadow(
|
||||
color: appTheme.black900.withValues(alpha: 0.15),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: visible ? appTheme.black : Colors.transparent,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
s.chosenPlanStartDateDescr,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
visible = !visible;
|
||||
});
|
||||
},
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/question_mark.svg',
|
||||
width: 22,
|
||||
height: 22,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
s.chose_plan_start_date_2,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
s.chose_plan_start_date_2,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
CalendarWidget(
|
||||
startDate: startDate,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
final result = await widget.chatCubit.choseStartPlanDate(startDate[0]);
|
||||
if (result) {
|
||||
showSuccessToast('Дата установлена');
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
showErrorToast('Не удалось установить дату');
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
const SizedBox(height: 12),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
CalendarWidget(
|
||||
startDate: startDate,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
final result = await widget.chatCubit.choseStartPlanDate(startDate[0]);
|
||||
if (result) {
|
||||
showSuccessToast('Дата установлена');
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
showErrorToast('Не удалось установить дату');
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -17,12 +18,12 @@ import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class NotificationBanner extends StatefulWidget {
|
||||
const NotificationBanner({super.key, required this.bannerType, this.planPrice, this.planName, this.chosenPlanId});
|
||||
const NotificationBanner({super.key, required this.bannerType, this.planPrice, this.planName, this.chosenPlanId, this.hasPersonalDiet = false});
|
||||
final BannerType bannerType;
|
||||
final double? planPrice;
|
||||
final String? planName;
|
||||
final int? chosenPlanId;
|
||||
|
||||
final bool hasPersonalDiet;
|
||||
@override
|
||||
State<NotificationBanner> createState() => _NotificationBannerState();
|
||||
}
|
||||
@@ -68,7 +69,8 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
BannerType.date => [
|
||||
SizedBox(
|
||||
width: size.width * 0.6,
|
||||
child: Text(s.choose_plan_start_date, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
child: Text(widget.hasPersonalDiet == true ? s.choose_plan_start_date_with_personal_diet : s.choose_plan_start_date,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
@@ -78,6 +80,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
context: context,
|
||||
builder: (context) => ChosePlanDateWidget(
|
||||
chatCubit: cubit,
|
||||
hasPersonalDiet: widget.hasPersonalDiet,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -155,6 +158,20 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
BannerType.setPersonalDiet => [
|
||||
SizedBox(
|
||||
width: size.width * 0.6,
|
||||
child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(MyDietsScreen.name);
|
||||
},
|
||||
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -242,4 +259,4 @@ class _StarIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum BannerType { date, card, payment, collectCharge, finishPlan }
|
||||
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/localization_utils.dart';
|
||||
@@ -47,7 +48,7 @@ class _OrderCardState extends State<OrderCard> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
'$baseUrl/storage/${widget.plan.image}',
|
||||
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -49,7 +51,7 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: TextButton(
|
||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '$baseUrl/docs/privacy.pdf' : '$baseUrl/oferta_bs.pdf')),
|
||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
||||
child: Text(
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
||||
|
||||
@@ -22,6 +22,7 @@ abstract class ConsultationModel with _$ConsultationModel {
|
||||
@JsonKey(name: 'end_date', fromJson: parseDate) required DateTime endDate,
|
||||
@JsonKey(name: 'consultant_id') required int consultantId,
|
||||
required List<DiaryModel> diaries,
|
||||
@JsonKey(name: 'personal_diet_id') required int? personalDietId,
|
||||
}) = _ConsultationModel;
|
||||
|
||||
factory ConsultationModel.fromJson(Map<String, dynamic> json) => _$ConsultationModelFromJson(json);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -54,6 +55,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
final nameController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||
bool _mdToolbarVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -76,9 +79,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(
|
||||
title: state.dietData.name ?? 'Диета',
|
||||
),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета'),
|
||||
);
|
||||
});
|
||||
} else if (state.drafted) {
|
||||
@@ -87,15 +88,15 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
context.pop();
|
||||
showBottomSheetWithBlur(
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(
|
||||
title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках',
|
||||
),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: appTheme.gray100,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
@@ -124,34 +125,23 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
context.read<CreateDietCubit>().setName(value);
|
||||
},
|
||||
),
|
||||
if (state.dietNameError != null) ...[
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
ValidationErrorWidget(error: state.dietNameError ?? ''),
|
||||
],
|
||||
if (state.dietNameError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietNameError ?? '')],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 132,
|
||||
child: CustomTextFormField(
|
||||
hasError: state.dietDescriptionError != null,
|
||||
controller: descriptionController,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
maxLines: null,
|
||||
minLines: null,
|
||||
expands: true,
|
||||
hintText: s.enter_diet_description,
|
||||
onChanged: (value) {
|
||||
context.read<CreateDietCubit>().setDetails(value);
|
||||
},
|
||||
),
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
initialText: descriptionController.text,
|
||||
hintText: s.enter_diet_description,
|
||||
hasError: state.dietDescriptionError != null,
|
||||
minLines: 6,
|
||||
maxLines: 8,
|
||||
showInlineToolbar: false,
|
||||
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||
onChanged: (value) {
|
||||
descriptionController.text = value;
|
||||
context.read<CreateDietCubit>().setDetails(value);
|
||||
},
|
||||
),
|
||||
if (state.dietDescriptionError != null) ...[
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
ValidationErrorWidget(error: state.dietDescriptionError ?? ''),
|
||||
],
|
||||
if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')],
|
||||
const SizedBox(height: 12),
|
||||
const DietDurationDropDownWidget(),
|
||||
const SizedBox(height: 12),
|
||||
@@ -171,9 +161,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${s.diet_plan} (${state.dietData.duration ?? 0} ${getDayForm(state.dietData.duration ?? 0, s)})',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -196,9 +184,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
|
||||
onPressed: () async {
|
||||
context.read<CreateDietCubit>().setDietState(DietState.draft);
|
||||
if (widget.diet != null) {
|
||||
final result = await context.read<CreateDietCubit>().updateDiet(
|
||||
widget.diet!.id,
|
||||
);
|
||||
final result = await context.read<CreateDietCubit>().updateDiet(widget.diet!.id);
|
||||
if (result == 'error') {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}');
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:developer';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/cubit/create_diet_state.dart';
|
||||
import 'package:eatvio_app/features/create_diet/repositories/create_diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
@@ -45,6 +48,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
type['veg'] = diet.type.veg ?? false;
|
||||
type['carb'] = diet.type.carb ?? false;
|
||||
type['light'] = diet.type.light ?? false;
|
||||
final appConfig = sl<AppConfig>();
|
||||
emit(
|
||||
state.copyWith.dietData(
|
||||
dailySchedule: dailySchedule,
|
||||
@@ -53,7 +57,8 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
name: diet.name,
|
||||
duration: diet.duration,
|
||||
type: type,
|
||||
imageUrl: '$baseUrl/storage/${diet.image}',
|
||||
// Avoid network image URLs during tests to prevent HTTP fetch attempts
|
||||
imageUrl: appConfig.buildType == AppBuildType.tests ? null : '${appConfig.baseUrl}/storage/${diet.image}',
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration)));
|
||||
@@ -74,11 +79,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
|
||||
if (currentDaysValue != null && dailySchedule != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
|
||||
if (currentDaysValue > selectedDaysValue) {
|
||||
final difference = currentDaysValue - selectedDaysValue;
|
||||
@@ -91,28 +92,16 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
deepCopyOfDailySchedule.add([[], [], [], [], []]);
|
||||
}
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
dietData: state.dietData.copyWith(
|
||||
dailySchedule: deepCopyOfDailySchedule,
|
||||
),
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(dietData: state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule)));
|
||||
} else {
|
||||
final List<List<List<DailySchedule>>> dailySchedule = List.generate(selectedDaysValue, (index) => [[], [], [], [], []]);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
dietData: state.dietData.copyWith(dailySchedule: dailySchedule),
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(dietData: state.dietData.copyWith(dailySchedule: dailySchedule)));
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
durationIndex: index,
|
||||
dietData: state.dietData.copyWith(
|
||||
duration: durationValues[index],
|
||||
),
|
||||
dietData: state.dietData.copyWith(duration: durationValues[index]),
|
||||
dietDurationError: null,
|
||||
),
|
||||
);
|
||||
@@ -123,7 +112,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
}
|
||||
|
||||
void setImage(XFile? image, {String? imageUrl}) {
|
||||
emit(state.copyWith(dietData: state.dietData.copyWith(image: image, imageUrl: imageUrl), dietImageError: null));
|
||||
emit(
|
||||
state.copyWith(
|
||||
dietData: state.dietData.copyWith(image: image, imageUrl: imageUrl),
|
||||
dietImageError: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setDailySchedule(List<List<List<DailySchedule>>> dailySchedule) {
|
||||
@@ -131,27 +125,25 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
}
|
||||
|
||||
void setDietState(DietState dietState) {
|
||||
emit(state.copyWith(dietData: state.dietData.copyWith(state: dietState), dietState: dietState, dietStateError: null));
|
||||
emit(
|
||||
state.copyWith(
|
||||
dietData: state.dietData.copyWith(state: dietState),
|
||||
dietState: dietState,
|
||||
dietStateError: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setDietType({required String key, required bool value}) {
|
||||
final type = Map<String, bool>.from(state.dietData.type);
|
||||
type[key] = value;
|
||||
emit(
|
||||
state.copyWith(
|
||||
dietData: state.dietData.copyWith(type: type),
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(dietData: state.dietData.copyWith(type: type)));
|
||||
}
|
||||
|
||||
void addPortion({required int dayIndex, required int mealIndex, required DailySchedule portion}) {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
if (dailySchedule != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
deepCopyOfDailySchedule[dayIndex][mealIndex].add(portion);
|
||||
|
||||
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
|
||||
@@ -160,18 +152,10 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
}
|
||||
}
|
||||
|
||||
void removePortion({
|
||||
required int day,
|
||||
required int meal,
|
||||
required int portionIndex,
|
||||
}) {
|
||||
void removePortion({required int day, required int meal, required int portionIndex}) {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
if (dailySchedule != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
deepCopyOfDailySchedule[day][meal].removeAt(portionIndex);
|
||||
|
||||
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
|
||||
@@ -182,11 +166,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
void updatePortion({required int dayIndex, required int mealIndex, required int portionIndex, required DailySchedule portion}) {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
if (dailySchedule != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
deepCopyOfDailySchedule[dayIndex][mealIndex][portionIndex] = portion;
|
||||
|
||||
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
|
||||
@@ -197,11 +177,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
void copyDay(int dayIndex) {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
if (dailySchedule != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
final copiedDay = deepCopyOfDailySchedule[dayIndex];
|
||||
emit(state.copyWith(copiedDay: copiedDay));
|
||||
}
|
||||
@@ -211,11 +187,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
final dailySchedule = state.dietData.dailySchedule;
|
||||
final copiedDay = state.copiedDay;
|
||||
if (dailySchedule != null && copiedDay != null) {
|
||||
final deepCopyOfDailySchedule = dailySchedule
|
||||
.map(
|
||||
(day) => day.map((meal) => [...meal]).toList(),
|
||||
)
|
||||
.toList();
|
||||
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
|
||||
deepCopyOfDailySchedule[dayIndex] = copiedDay;
|
||||
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
|
||||
emit(state.copyWith(dietData: newDietData));
|
||||
@@ -266,14 +238,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
|
||||
final dailyNutrition = <DailyNutrition>[];
|
||||
if (carbs != null && calories != null && fats != null && proteins != null) {
|
||||
for (int i = 0; i < carbs.length; i++) {
|
||||
dailyNutrition.add(
|
||||
DailyNutrition(
|
||||
carbohydrates: carbs[i],
|
||||
energy: calories[i],
|
||||
fats: fats[i],
|
||||
proteins: proteins[i],
|
||||
),
|
||||
);
|
||||
dailyNutrition.add(DailyNutrition(carbohydrates: carbs[i], energy: calories[i], fats: fats[i], proteins: proteins[i]));
|
||||
}
|
||||
}
|
||||
final dailySchedule = <int, Map<int, Map<int, DailySchedule>>>{};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -28,16 +29,21 @@ class CreatePlanScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<CreatePlanCubit>(),
|
||||
child: const CreatePlanScreenContent(),
|
||||
);
|
||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
||||
}
|
||||
}
|
||||
|
||||
class CreatePlanScreenContent extends StatelessWidget {
|
||||
class CreatePlanScreenContent extends StatefulWidget {
|
||||
const CreatePlanScreenContent({super.key});
|
||||
|
||||
@override
|
||||
State<CreatePlanScreenContent> createState() => _CreatePlanScreenContentState();
|
||||
}
|
||||
|
||||
class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||
bool _mdToolbarVisible = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -51,15 +57,14 @@ class CreatePlanScreenContent extends StatelessWidget {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(
|
||||
title: state.planData.name ?? 'План',
|
||||
isPlan: true,
|
||||
),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: appTheme.gray100,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
@@ -104,10 +109,7 @@ class CreatePlanScreenContent extends StatelessWidget {
|
||||
},
|
||||
maxLength: 150,
|
||||
),
|
||||
if (state.validationState.titleHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
if (state.validationState.titleHasError) ...[const SizedBox(height: 4), ValidationErrorWidget(error: s.atLeast3Symbols)],
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -140,20 +142,17 @@ class CreatePlanScreenContent extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 132,
|
||||
child: CustomTextFormField(
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
maxLength: 1000,
|
||||
maxLines: null,
|
||||
minLines: null,
|
||||
expands: true,
|
||||
hintText: s.plan_desc_example,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setDetails(value);
|
||||
},
|
||||
),
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
hintText: s.plan_desc_example,
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
minLines: 6,
|
||||
maxLines: 8,
|
||||
showInlineToolbar: false,
|
||||
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setDetails(value);
|
||||
},
|
||||
),
|
||||
if (state.validationState.descriptionHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
@@ -253,10 +252,7 @@ class CreatePlanScreenContent extends StatelessWidget {
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(bottom: 24.0),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SelectExactOptionWidget(),
|
||||
),
|
||||
child: Align(alignment: Alignment.bottomCenter, child: SelectExactOptionWidget()),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -67,6 +67,13 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
validateSelectedOptionItems();
|
||||
}
|
||||
|
||||
void togglePersonalizedDietOptionState() {
|
||||
final options = [...state.planData.options];
|
||||
options[state.selectedOptionIndex ?? 0] =
|
||||
options[state.selectedOptionIndex ?? 0].copyWith(hasPersonalDiet: !options[state.selectedOptionIndex ?? 0].hasPersonalDiet);
|
||||
emit(state.copyWith(planData: state.planData.copyWith(options: options)));
|
||||
}
|
||||
|
||||
void setDuration(int index) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -418,18 +425,21 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet2_options': {
|
||||
'short': state.planData.options[1].short,
|
||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
||||
'description_md': state.planData.options[1].description,
|
||||
'items': Map.fromIterable(state.planData.options[1].items),
|
||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet3_options': {
|
||||
'short': state.planData.options[2].short,
|
||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
||||
'description_md': state.planData.options[2].description,
|
||||
'items': Map.fromIterable(state.planData.options[2].items),
|
||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -440,6 +450,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
2 => thirdOptionCustomerPriceController,
|
||||
_ => secondOptionCustomerPriceController,
|
||||
};
|
||||
|
||||
|
||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
||||
@@ -255,33 +255,31 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const CustomDivider(),
|
||||
// const SizedBox(height: 16),
|
||||
// Row(
|
||||
// children: [
|
||||
// Checkbox(
|
||||
// value: isChecked,
|
||||
// activeColor: appTheme.gray,
|
||||
// checkColor: appTheme.gray,
|
||||
// fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
// side: WidgetStateBorderSide.resolveWith(
|
||||
// (Set<WidgetState> states) {
|
||||
// return BorderSide(width: 1, color: appTheme.gray);
|
||||
// },
|
||||
// ),
|
||||
// onChanged: (bool? value) {
|
||||
// setState(() {
|
||||
// isChecked = value ?? false;
|
||||
// });
|
||||
// },
|
||||
// ),
|
||||
// Expanded(
|
||||
// child: Text(
|
||||
// s.option_personal_diet_desc,
|
||||
// style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
||||
activeColor: appTheme.gray,
|
||||
checkColor: appTheme.gray,
|
||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
side: WidgetStateBorderSide.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
},
|
||||
),
|
||||
onChanged: (bool? value) {
|
||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.option_personal_diet_desc,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as i;
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
|
||||
@@ -187,13 +188,13 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
}
|
||||
|
||||
Future<void> recipeToEntity(Recipe recipe) async {
|
||||
final file = await downloadImageAsFile('$baseUrl/storage/${recipe.image}');
|
||||
final file = await downloadImageAsFile('${sl<AppConfig>().baseUrl}/storage/${recipe.image}');
|
||||
|
||||
final List<StepEntity> steps = await Future.wait(
|
||||
recipe.steps!.map(
|
||||
(e) async => StepEntity(
|
||||
description: e.description,
|
||||
image: e.image.isEmpty ? null : await downloadImageAsFile('$baseUrl/storage/${e.image}'),
|
||||
image: e.image.isEmpty ? null : await downloadImageAsFile('${sl<AppConfig>().baseUrl}/storage/${e.image}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.da
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
|
||||
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
@@ -18,10 +20,7 @@ import 'package:go_router/go_router.dart';
|
||||
class RecipeCreateScreen extends StatefulWidget {
|
||||
final Recipe? recipe;
|
||||
|
||||
const RecipeCreateScreen({
|
||||
super.key,
|
||||
this.recipe,
|
||||
});
|
||||
const RecipeCreateScreen({super.key, this.recipe});
|
||||
|
||||
static const name = 'recipeCreateScreen';
|
||||
static const path = '/recipe-create-screen';
|
||||
@@ -32,6 +31,8 @@ class RecipeCreateScreen extends StatefulWidget {
|
||||
|
||||
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
late final CreateRecipeCubit createRecipeCubit;
|
||||
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
|
||||
bool _mdToolbarVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,20 +53,18 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
child: BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
builder: (BuildContext context, CreateRecipeState state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(
|
||||
body: Loader(),
|
||||
);
|
||||
return const Scaffold(body: Loader());
|
||||
} else {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF0F2F5),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null
|
||||
? _mdKey.currentState!.buildToolbar(context)
|
||||
: null,
|
||||
appBar: AppBar(
|
||||
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
|
||||
centerTitle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
@@ -75,22 +74,23 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
children: [
|
||||
InfoAboutRecipeWidget(
|
||||
createRecipeCubit: createRecipeCubit,
|
||||
markdownEditorKey: _mdKey,
|
||||
onMarkdownToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
|
||||
),
|
||||
const IngredientsWidget(),
|
||||
StepsWidget(
|
||||
createRecipeCubit: createRecipeCubit,
|
||||
),
|
||||
StepsWidget(createRecipeCubit: createRecipeCubit),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
builder: (context, state) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return InkWell(
|
||||
key: AppKeys.recipeCreateScreenPublishButton,
|
||||
onTap: () async {
|
||||
if (context.read<CreateRecipeCubit>().validateAll()) {
|
||||
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
|
||||
widget.recipe == null ? false : true,
|
||||
widget.recipe?.slug,
|
||||
);
|
||||
widget.recipe == null ? false : true,
|
||||
widget.recipe?.slug,
|
||||
);
|
||||
if (isSuccess) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (!context.mounted) return;
|
||||
@@ -98,9 +98,7 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedRecipeBottomSheet(
|
||||
title: state.entity.title ?? 'Рецепт',
|
||||
),
|
||||
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -112,20 +110,13 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
widget.recipe != null ? 'Обновить' : 'Опубликовать',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:easy_debounce/easy_debounce.dart';
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
@@ -12,13 +13,7 @@ import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AddPortionWidget extends StatefulWidget {
|
||||
const AddPortionWidget({
|
||||
super.key,
|
||||
required this.createRecipeCubit,
|
||||
this.portionIndex,
|
||||
this.productNameControllerText,
|
||||
this.quantityControllerText,
|
||||
});
|
||||
const AddPortionWidget({super.key, required this.createRecipeCubit, this.portionIndex, this.productNameControllerText, this.quantityControllerText});
|
||||
final CreateRecipeCubit createRecipeCubit;
|
||||
final String? productNameControllerText;
|
||||
final String? quantityControllerText;
|
||||
@@ -43,76 +38,65 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16) +
|
||||
EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
padding: const EdgeInsets.all(16) + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Добавление блюда',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenIngredientNameField,
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce(
|
||||
'glossary-search',
|
||||
const Duration(milliseconds: 250),
|
||||
() async {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
selectedGlossary = null;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createRecipeCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createRecipeCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenIngredientQuantityField,
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
glossariesList.isEmpty ? const SizedBox(height: 16) : const SizedBox.shrink(),
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 256,
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 256),
|
||||
child: ListView.separated(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
@@ -127,28 +111,12 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
setState(() {});
|
||||
FocusManager.instance.primaryFocus?.nextFocus();
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: Text(
|
||||
glossariesList[i].name,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Text(glossariesList[i].name, style: const TextStyle(height: 1)),
|
||||
subtitle: glossariesList[i].grInPortion == 0
|
||||
? null
|
||||
: Text(
|
||||
'${glossariesList[i].grInPortion} г',
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.add_rounded,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
: Text('${glossariesList[i].grInPortion} г', style: TextStyle(height: 1, color: Colors.grey.shade600)),
|
||||
trailing: Icon(Icons.add_rounded, color: Colors.grey.shade400),
|
||||
contentPadding: const EdgeInsets.only(left: 12),
|
||||
);
|
||||
},
|
||||
@@ -170,34 +138,23 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
child: Ink(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0xFFF6F6F6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.red,
|
||||
),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
|
||||
child: const Icon(Icons.delete_outline_rounded, color: Colors.red),
|
||||
),
|
||||
),
|
||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => addPortion(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: AppKeys.recipeCreateScreenIngredientAddButton,
|
||||
onTap: () => addPortion(context),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -215,8 +172,8 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
var measurementUnits = selectedGlossary?.grInPortion == null
|
||||
? Portion.pcs
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? Portion.grams
|
||||
: Portion.pcs;
|
||||
? Portion.grams
|
||||
: Portion.pcs;
|
||||
final glossary = selectedGlossary;
|
||||
if (glossary != null) {
|
||||
final [calories, carbohydrates, proteins, fats] = _getCPFC(glossary, measurementUnits);
|
||||
@@ -229,12 +186,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
);
|
||||
if (portionIndex == null) {
|
||||
widget.createRecipeCubit.addIngredient(
|
||||
Ingredient(
|
||||
name: portion.name,
|
||||
unit: portion.portion.getValue(),
|
||||
portion: portion.quantity.toString(),
|
||||
productId: glossary.id,
|
||||
),
|
||||
Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: glossary.id),
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -247,13 +199,8 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: GlossaryModerationRequestModalWidget(
|
||||
productName: productNameController.text,
|
||||
diaryCubit: sl<DiaryCubit>(),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: sl<DiaryCubit>()),
|
||||
),
|
||||
);
|
||||
if (newGlossary != null) {
|
||||
@@ -268,12 +215,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
);
|
||||
if (portionIndex == null) {
|
||||
widget.createRecipeCubit.addIngredient(
|
||||
Ingredient(
|
||||
name: portion.name,
|
||||
unit: portion.portion.getValue(),
|
||||
portion: portion.quantity.toString(),
|
||||
productId: newGlossary.id,
|
||||
),
|
||||
Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: newGlossary.id),
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -281,12 +223,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
} else {
|
||||
widget.createRecipeCubit.updateIngredient(
|
||||
index: portionIndex,
|
||||
ingredient: Ingredient(
|
||||
name: portion.name,
|
||||
unit: portion.portion.getValue(),
|
||||
portion: portion.quantity.toString(),
|
||||
productId: newGlossary.id,
|
||||
),
|
||||
ingredient: Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: newGlossary.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_drop_down.dart';
|
||||
@@ -15,8 +17,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class InfoAboutRecipeWidget extends StatefulWidget {
|
||||
final CreateRecipeCubit createRecipeCubit;
|
||||
final GlobalKey<MarkdownEditorState>? markdownEditorKey;
|
||||
final void Function(bool visible)? onMarkdownToolbarVisibilityChanged;
|
||||
|
||||
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
|
||||
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit, this.markdownEditorKey, this.onMarkdownToolbarVisibilityChanged});
|
||||
|
||||
@override
|
||||
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
|
||||
@@ -46,125 +50,126 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Название'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
bloc: widget.createRecipeCubit,
|
||||
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
hintText: 'Например: тар-тар из говядины',
|
||||
hasError: state.titleValidationError != null,
|
||||
initialValue: state.entity.title,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceTitle(value);
|
||||
if (state.titleValidationError != null) {
|
||||
widget.createRecipeCubit.validateTitle();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (state.titleValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.titleValidationError!,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Название'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
bloc: widget.createRecipeCubit,
|
||||
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenTitleField,
|
||||
hintText: 'Например: тар-тар из говядины',
|
||||
hasError: state.titleValidationError != null,
|
||||
initialValue: state.entity.title,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceTitle(value);
|
||||
if (state.titleValidationError != null) {
|
||||
widget.createRecipeCubit.validateTitle();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (state.titleValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.titleValidationError!)],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Тип блюда'),
|
||||
const SizedBox(height: 4),
|
||||
CustomDropDown(
|
||||
items: items,
|
||||
value: selectedType,
|
||||
onChanged: (newSelectedType) {
|
||||
setState(() {
|
||||
selectedType = newSelectedType;
|
||||
});
|
||||
widget.createRecipeCubit.changeType(
|
||||
TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const TagsWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const PortionWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const TimeOfCookWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
buildWhen: (previous, current) => previous.descriptionValidationError != current.descriptionValidationError,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
|
||||
minLines: 5,
|
||||
initialValue: state.entity.description,
|
||||
maxLines: 5,
|
||||
hasError: state.descriptionValidationError != null,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceDescription(value);
|
||||
if (state.descriptionValidationError != null) {
|
||||
widget.createRecipeCubit.validateDescription();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (state.descriptionValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.descriptionValidationError!,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Тип блюда'),
|
||||
const SizedBox(height: 4),
|
||||
CustomDropDown(
|
||||
key: AppKeys.recipeCreateScreenTypeDropdown,
|
||||
items: items,
|
||||
value: selectedType,
|
||||
onChanged: (newSelectedType) {
|
||||
setState(() {
|
||||
selectedType = newSelectedType;
|
||||
});
|
||||
widget.createRecipeCubit.changeType(TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const TagsWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const PortionWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const TimeOfCookWidget(),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
buildWhen: (previous, current) => previous.descriptionValidationError != current.descriptionValidationError,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
KeyedSubtree(
|
||||
key: AppKeys.recipeCreateScreenDescriptionField,
|
||||
child: MarkdownEditor(
|
||||
key: widget.markdownEditorKey,
|
||||
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
|
||||
minLines: 5,
|
||||
maxLines: 5,
|
||||
initialText: state.entity.description,
|
||||
hasError: state.descriptionValidationError != null,
|
||||
showInlineToolbar: false,
|
||||
onToolbarVisibilityChanged: widget.onMarkdownToolbarVisibilityChanged,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceDescription(value);
|
||||
if (state.descriptionValidationError != null) {
|
||||
widget.createRecipeCubit.validateDescription();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.descriptionValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionValidationError!),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Фото обложка'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
buildWhen: (previous, current) => previous.imageValidationError != current.imageValidationError || previous.entity.image != current.entity.image,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: state.imageValidationError != null
|
||||
? BoxDecoration(border: Border.all(width: 1, color: appTheme.red), borderRadius: BorderRadius.circular(8))
|
||||
: null,
|
||||
child: ImageWidget(image: state.entity.image),
|
||||
),
|
||||
if (state.imageValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.imageValidationError!,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Фото обложка'),
|
||||
const SizedBox(height: 4),
|
||||
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.imageValidationError != current.imageValidationError || previous.entity.image != current.entity.image,
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: state.imageValidationError != null
|
||||
? BoxDecoration(
|
||||
border: Border.all(width: 1, color: appTheme.red),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
: null,
|
||||
child: ImageWidget(key: AppKeys.recipeCreateScreenImageWidget, image: state.entity.image),
|
||||
),
|
||||
if (state.imageValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.imageValidationError!)],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Ссылка на видео (если есть)'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Прикрепите ссылку на видео',
|
||||
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceVideoUrl(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Ссылка на видео (если есть)'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Прикрепите ссылку на видео',
|
||||
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceVideoUrl(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
|
||||
@@ -40,11 +41,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Ингредиенты ',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
|
||||
),
|
||||
),
|
||||
ReorderableListView(
|
||||
@@ -63,46 +60,29 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
key: Key('$index'),
|
||||
leading: Icon(
|
||||
Icons.drag_handle_rounded,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
leading: Icon(Icons.drag_handle_rounded, color: Colors.grey.shade600),
|
||||
title: Text(state.entity.ingredients[index].name),
|
||||
subtitle: Text('${state.entity.ingredients[index].portion} ${state.entity.ingredients[index].unit == 'gr' ? 'гр' : 'шт'}'),
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
createRecipeCubit.removeIngredient(index);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
icon: Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
key: AppKeys.recipeCreateScreenAddIngredientButton,
|
||||
onTap: () {
|
||||
nameController.clear();
|
||||
gramsController.clear();
|
||||
pcsController.clear();
|
||||
_modalBottomSheet(
|
||||
createRecipeCubit: createRecipeCubit,
|
||||
);
|
||||
_modalBottomSheet(createRecipeCubit: createRecipeCubit);
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
title: Text(
|
||||
'Добавить ингредиент',
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
leading: Icon(
|
||||
Icons.add_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)),
|
||||
leading: Icon(Icons.add_rounded, color: appTheme.blueA400),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -121,9 +101,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
|
||||
return showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
isScrollControlled: true,
|
||||
builder: (context) {
|
||||
return AddPortionWidget(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
@@ -39,12 +40,13 @@ class _PortionWidgetState extends State<PortionWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Кол-во порций'),
|
||||
const Text('Количество порций'),
|
||||
const SizedBox(height: 4),
|
||||
Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
hintText: '1',
|
||||
key: AppKeys.recipeCreateScreenPortionsField,
|
||||
hintText: 'Например: 1',
|
||||
hasError: state.servingsValidationError != null,
|
||||
initialValue: state.entity.countOfPortions?.toString(),
|
||||
textInputType: TextInputType.number,
|
||||
@@ -57,9 +59,7 @@ class _PortionWidgetState extends State<PortionWidget> {
|
||||
),
|
||||
if (state.servingsValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.servingsValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.servingsValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -72,11 +72,12 @@ class _PortionWidgetState extends State<PortionWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Ккал в одной порции'),
|
||||
const Text('Калорийность'),
|
||||
const SizedBox(height: 4),
|
||||
Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenCaloriesField,
|
||||
hintText: '100',
|
||||
hasError: state.energyPerServingValidationError != null,
|
||||
initialValue: state.entity.energyInOnePortion?.toString(),
|
||||
@@ -91,9 +92,7 @@ class _PortionWidgetState extends State<PortionWidget> {
|
||||
),
|
||||
if (state.energyPerServingValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.energyPerServingValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.energyPerServingValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
@@ -22,107 +23,92 @@ class _StepsWidgetState extends State<StepsWidget> {
|
||||
bloc: widget.createRecipeCubit,
|
||||
builder: (BuildContext context, CreateRecipeState state) {
|
||||
return Material(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Пошаговая инструкция',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Пошаговая инструкция',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Шаг ${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CreateRecipeCubit>().removeStep(state.entity.steps[index]);
|
||||
},
|
||||
child: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Например: подготовьте ингредиенты',
|
||||
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
|
||||
maxLines: 5,
|
||||
minLines: 3,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Фото шага'),
|
||||
const SizedBox(height: 4),
|
||||
StepImageWidget(
|
||||
index: index,
|
||||
createRecipeCubit: widget.createRecipeCubit,
|
||||
),
|
||||
],
|
||||
),
|
||||
itemCount: state.entity.steps.length,
|
||||
separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.read<CreateRecipeCubit>().addStep();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Добавить шаг',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
key: AppKeys.recipeCreateScreenAddStepButton,
|
||||
onTap: () {
|
||||
context.read<CreateRecipeCubit>().addStep();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'Добавить шаг',
|
||||
style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(
|
||||
'Шаг ${index + 1}',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CreateRecipeCubit>().removeStep(state.entity.steps[index]);
|
||||
},
|
||||
child: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenStepDescriptionField,
|
||||
hintText: 'Например: подготовьте ингредиенты',
|
||||
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
|
||||
maxLines: 5,
|
||||
minLines: 3,
|
||||
onChanged: (value) {
|
||||
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Фото шага'),
|
||||
const SizedBox(height: 4),
|
||||
StepImageWidget(
|
||||
key: Key('${AppKeys.recipeCreateScreenStepImageWidget}_$index'),
|
||||
index: index,
|
||||
createRecipeCubit: widget.createRecipeCubit,
|
||||
),
|
||||
],
|
||||
),
|
||||
itemCount: state.entity.steps.length,
|
||||
separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
|
||||
@@ -26,9 +27,11 @@ class _TagsWidgetState extends State<TagsWidget> {
|
||||
const Text('Теги'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.recipeCreateScreenTagField,
|
||||
hintText: 'Например: без майонеза',
|
||||
controller: tagController,
|
||||
suffix: IconButton(
|
||||
key: AppKeys.recipeCreateScreenAddTagButton,
|
||||
onPressed: () {
|
||||
if (tagController.text.isNotEmpty) {
|
||||
context.read<CreateRecipeCubit>().addTag(tagController.text);
|
||||
@@ -37,10 +40,7 @@ class _TagsWidgetState extends State<TagsWidget> {
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.done,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
icon: Icon(Icons.done, color: appTheme.blueA400),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -56,22 +56,13 @@ class _TagsWidgetState extends State<TagsWidget> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: Colors.grey.shade200),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.entity.tags[i],
|
||||
),
|
||||
Text(state.entity.tags[i]),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: Colors.grey.shade600,
|
||||
size: 20,
|
||||
),
|
||||
Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
@@ -43,20 +44,12 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Text(
|
||||
'Общее время приготовления - это период времени от начала приготовления блюда до подачи его на стол.',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1.2,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.help_outline_rounded,
|
||||
color: appTheme.blueA400,
|
||||
size: 20,
|
||||
),
|
||||
child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -66,9 +59,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Часы'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Часов',
|
||||
key: AppKeys.recipeCreateScreenTotalHoursField,
|
||||
hintText: '',
|
||||
hasError: state.cookingTimeValidationError != null,
|
||||
textInputType: TextInputType.number,
|
||||
initialValue: state.entity.totalCookHours?.toString(),
|
||||
@@ -83,9 +80,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
),
|
||||
if (state.cookingTimeValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.cookingTimeValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.cookingTimeValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -93,9 +88,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Минуты'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Минут',
|
||||
key: AppKeys.recipeCreateScreenTotalMinutesField,
|
||||
hintText: '',
|
||||
hasError: state.cookingTimeValidationError != null,
|
||||
textInputType: TextInputType.number,
|
||||
initialValue: state.entity.totalCookMinutes?.toString(),
|
||||
@@ -110,9 +109,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
),
|
||||
if (state.cookingTimeValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.cookingTimeValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.cookingTimeValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -131,20 +128,12 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
builder: (context) => const AlertDialog(
|
||||
content: Text(
|
||||
'Активное время приготовления - это период времени, требующий активного участия в приготовлении блюда. Оно включает мытье и очистку продуктов, нарезку, шинковку, взбивание, перемешивание, размешивание, перекладывание и т.п.',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1.2,
|
||||
),
|
||||
style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.help_outline_rounded,
|
||||
color: appTheme.blueA400,
|
||||
size: 20,
|
||||
),
|
||||
child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -154,9 +143,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Часы'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Часов',
|
||||
key: AppKeys.recipeCreateScreenActiveHoursField,
|
||||
hintText: '',
|
||||
hasError: state.activeTimeValidationError != null,
|
||||
initialValue: state.entity.activeCookHours?.toString(),
|
||||
textInputType: TextInputType.number,
|
||||
@@ -172,9 +165,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
),
|
||||
if (state.activeTimeValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.activeTimeValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.activeTimeValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -182,9 +173,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Минуты'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: 'Минут',
|
||||
key: AppKeys.recipeCreateScreenActiveMinutesField,
|
||||
hintText: '',
|
||||
hasError: state.activeTimeValidationError != null,
|
||||
initialValue: state.entity.activeCookMinutes?.toString(),
|
||||
textInputType: TextInputType.number,
|
||||
@@ -200,9 +195,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
|
||||
),
|
||||
if (state.activeTimeValidationError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(
|
||||
error: state.activeTimeValidationError!,
|
||||
),
|
||||
ValidationErrorWidget(error: state.activeTimeValidationError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
@@ -84,7 +84,7 @@ class _DevToolsScreenContentState extends State<DevToolsScreenContent> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
setState(() {
|
||||
baseUrlForTest = baseUrl;
|
||||
baseUrlForTest = sl<AppConfig>().baseUrl;
|
||||
});
|
||||
testsStates = List.generate(testCases.length, (index) => TestState.notRun);
|
||||
}
|
||||
@@ -111,8 +111,8 @@ class _DevToolsScreenContentState extends State<DevToolsScreenContent> {
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
baseUrlForTest = controller.text;
|
||||
baseUrl = baseUrlForTest.toLowerCase();
|
||||
sl<Dio>().options.baseUrl = baseUrl;
|
||||
sl<AppConfig>().setBaseUrl(baseUrlForTest.toLowerCase());
|
||||
sl<Dio>().options.baseUrl = baseUrlForTest.toLowerCase();
|
||||
controller.clear();
|
||||
});
|
||||
},
|
||||
@@ -122,23 +122,23 @@ class _DevToolsScreenContentState extends State<DevToolsScreenContent> {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
CustomOutlinedButton(
|
||||
text: baseUrl == EnvConfig.devBaseUrl ? 'Set production base url' : 'Set dev2 base url',
|
||||
text: sl<AppConfig>().baseUrl == EnvConfig.devBaseUrl ? 'Set production base url' : 'Set dev2 base url',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (baseUrl == EnvConfig.baseUrl) {
|
||||
if (sl<AppConfig>().baseUrl == EnvConfig.baseUrl) {
|
||||
baseUrlForTest = EnvConfig.devBaseUrl;
|
||||
} else {
|
||||
baseUrlForTest = EnvConfig.baseUrl;
|
||||
}
|
||||
baseUrl = baseUrlForTest.toLowerCase();
|
||||
sl<Dio>().options.baseUrl = baseUrl;
|
||||
sl<AppConfig>().setBaseUrl(baseUrlForTest.toLowerCase());
|
||||
sl<Dio>().options.baseUrl = baseUrlForTest.toLowerCase();
|
||||
controller.clear();
|
||||
});
|
||||
},
|
||||
buttonStyle: const ButtonStyle(),
|
||||
),
|
||||
SizedBox(height: baseUrlForTest != baseUrl ? 40 : 10),
|
||||
if (baseUrl.toLowerCase() != EnvConfig.baseUrl.toLowerCase())
|
||||
SizedBox(height: baseUrlForTest != sl<AppConfig>().baseUrl ? 40 : 10),
|
||||
if (sl<AppConfig>().baseUrl.toLowerCase() != EnvConfig.baseUrl.toLowerCase())
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: testCases.length,
|
||||
|
||||
22
lib/features/diary/entities/diary_day_entity.dart
Normal file
22
lib/features/diary/entities/diary_day_entity.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_day_entity.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class DiaryDayEntity with _$DiaryDayEntity {
|
||||
factory DiaryDayEntity({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "date") required String date,
|
||||
@JsonKey(name: "wakeup") String? wakeup,
|
||||
@JsonKey(name: "retire") String? retire,
|
||||
@JsonKey(name: "closed") required int closed,
|
||||
@JsonKey(name: "comment") required String comment,
|
||||
@JsonKey(name: "state") required TypeOfDiaryModel typeOfDiary,
|
||||
@JsonKey(name: "meals") required List<MealModel> meals,
|
||||
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
|
||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||
}) = _DiaryDayEntity;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_future_diet_day_entity.freezed.dart';
|
||||
|
||||
|
||||
|
||||
@freezed
|
||||
abstract class DiaryFutureDietDayEntity with _$DiaryFutureDietDayEntity {
|
||||
const factory DiaryFutureDietDayEntity({
|
||||
required String date,
|
||||
required TypeOfDiaryModel typeOfDiary,
|
||||
required List<MealModel> meals,
|
||||
required DiaryDietModel? diet,
|
||||
required int? dayOfChosenDiet,
|
||||
}) = _DiaryFutureDietDayEntity;
|
||||
|
||||
}
|
||||
11
lib/features/diary/models/create_day_model.dart
Normal file
11
lib/features/diary/models/create_day_model.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'create_day_model.freezed.dart';
|
||||
part 'create_day_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class CreateDayModel with _$CreateDayModel {
|
||||
factory CreateDayModel({required String day}) = _CreateDayModel;
|
||||
|
||||
factory CreateDayModel.fromJson(Map<String, dynamic> json) => _$CreateDayModelFromJson(json);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user