Compare commits

..

131 Commits

Author SHA1 Message Date
misha
1b948b99e3 Merge remote-tracking branch 'upstream/main' into issue-738 2025-11-19 11:16:45 +03:00
misha
b6eac3c46f feat(registration): fixed registration on android devices, feat(leaders): fixed diet creation loader 2025-11-19 11:07:16 +03:00
mso7nueh
39775cd7a8 Merge pull request 'updated app version' (#774) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#774
2025-11-17 10:22:48 +00:00
Username1548
36e2f12e3f updated app version 2025-11-17 12:01:31 +03:00
mso7nueh
66d2533add Merge pull request 'fix: Issue-752' (#769) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#769
2025-11-14 10:34:42 +00:00
mso7nueh
425f16bfc9 fix(consultation): Issue-752 2025-11-13 19:17:28 +03:00
mso7nueh
c5e30d8f00 Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-11-13 18:59:48 +03:00
mso7nueh
d0935fbaae feat: upd flutter 2025-11-13 18:58:56 +03:00
dda0aa7440 Merge pull request 'fix: tests fix' (#768) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#768
2025-11-13 08:21:48 +00:00
misha
c81327b895 fix: tests fix 2025-11-13 11:21:02 +03:00
732d667ac1 Merge pull request 'improvements: added loaders' (#766) from misha/eatvio_app:issue-580 into main
Reviewed-on: eatvio/eatvio_app#766
2025-11-13 07:13:00 +00:00
misha
ba9f4a3584 improvements: added loaders 2025-11-13 10:12:11 +03:00
mso7nueh
f63d901a55 Merge pull request 'feat(ci/cd): add coverage test' (#764) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#764
2025-11-12 11:03:23 +00:00
mso7nueh
9148a4c668 feat(ci/cd): add coverage test 2025-11-12 13:34:55 +03:00
mso7nueh
a3f0aa3e67 Merge pull request 'feat(oauth): Issue-691+180' (#762) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#762
2025-11-11 14:40:09 +00:00
mso7nueh
8a390c2a9b Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-11-11 17:38:23 +03:00
mso7nueh
92b37e77b1 fix(socail,diets): Issue-180+691 2025-11-11 17:38:15 +03:00
101c1e15af Merge pull request 'feat(registration)' (#739) from misha/eatvio_app:issue-738 into main
Reviewed-on: eatvio/eatvio_app#739
2025-11-11 07:24:37 +00:00
mso7nueh
172c05ac69 merge upstream 2025-11-10 21:16:17 +00:00
misha
4eecc458f8 fix: formatting 2025-11-10 18:59:03 +03:00
misha
4b781adb79 feat(registration): added api integration for short registration 2025-11-10 18:55:55 +03:00
mso7nueh
d1160ba35f feat(oauth): Issue-691 2025-11-07 01:38:00 +03:00
mso7nueh
a46ac5bb4c Merge pull request 'feat(diary_test): Issue-755' (#759) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#759
2025-11-06 19:05:03 +00:00
mso7nueh
2a06a5476a feat(diary_test): Issue-755 2025-11-06 00:38:08 +03:00
mso7nueh
f3853b58d9 Merge pull request 'fix: Issue-757' (#758) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#758
2025-11-05 21:17:36 +00:00
mso7nueh
bbc99ec9f4 fix(settings): Issue-757 2025-11-05 23:55:48 +03:00
ff946a5938 Merge pull request 'feat(personalDiets)' (#561) from misha/eatvio_app:personalDiets into main
Reviewed-on: eatvio/eatvio_app#561
2025-10-31 10:27:20 +00:00
misha
a72c40d8ee Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-31 13:26:05 +03:00
misha
6ed7faed15 feat(personalDiets): addde base url from app config for consultation widget 2025-10-31 13:23:13 +03:00
mso7nueh
12f51d49ea fix(profile): subs screen and blog post padding 2025-10-31 12:59:02 +03:00
misha
16fccaea8e Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-31 12:41:36 +03:00
Username1548
a723673d23 Merge branch 'main' of http://gitea.office.local:3000/eatvio/eatvio_app 2025-10-31 12:39:49 +03:00
Username1548
89f697f7a6 changed app version 2025-10-31 12:38:02 +03:00
d883c9e19a Merge pull request 'feat(marketing)' (#745) from misha/eatvio_app:issue-689 into main
Reviewed-on: eatvio/eatvio_app#745
2025-10-31 09:10:18 +00:00
misha
884830f232 Merge remote-tracking branch 'upstream/main' into issue-689 2025-10-31 12:08:02 +03:00
mso7nueh
7b297f954e Merge pull request 'Issue-746' (#747) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#747
2025-10-31 08:59:06 +00:00
mso7nueh
dbd92a65ea feat(md): Issue-746 2025-10-31 00:20:21 +03:00
mso7nueh
f3af743487 merge upstream 2025-10-30 19:27:36 +00:00
Username1548
c95f1988ae commit to create new build 2025-10-30 17:13:58 +03:00
mso7nueh
35a5e6d6be merge upstream 2025-10-30 09:13:28 +00:00
mso7nueh
977b7150fe feat(md): Issue-746 2025-10-29 17:27:05 +03:00
5b81014e72 Merge pull request 'feat(InfoByOrder)' (#750) from misha/eatvio_app:issue-681 into main
Reviewed-on: eatvio/eatvio_app#750
2025-10-29 01:39:05 +00:00
misha
563d84ca12 feat(InfoByOrder): added user info to InfoByOrderScreen author card 2025-10-29 04:38:04 +03:00
06cafb4257 Merge pull request 'feat(profile)' (#749) from misha/eatvio_app:issue-742 into main
Reviewed-on: eatvio/eatvio_app#749
2025-10-29 01:02:36 +00:00
misha
a34cb01c1f feat(profile): changed profile bages layout 2025-10-29 04:01:27 +03:00
mso7nueh
8799375880 feat(md): Issue-746 2025-10-27 17:07:20 +03:00
mso7nueh
fb5a0d52bc feat(feed): mdToHtml 2025-10-24 18:55:01 +03:00
misha
2e3172bc45 Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-24 13:21:03 +03:00
misha
41313f09af feat(marketing): added marketing bottom sheet 2025-10-23 17:25:12 +03:00
mso7nueh
ce0099c385 Merge pull request 'feat(tests): blog post create' (#744) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#744
2025-10-22 15:42:27 +00:00
mso7nueh
14e4d7973e feat(tests): blog post create 2025-10-22 18:42:01 +03:00
mso7nueh
a138707111 Merge pull request 'feat(test): diet, plan, registration' (#741) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#741
2025-10-22 13:48:38 +00:00
mso7nueh
4c7dd4a12a Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-10-22 16:48:14 +03:00
mso7nueh
c2eda628dd WIP: feat(tests): blog post create 2025-10-22 16:48:08 +03:00
mso7nueh
53f2b78dfa Merge pull request 'feat(test): diet, plan, registration' (#741) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#741
2025-10-22 13:47:07 +00:00
misha
e147f6e182 feat(registration): finished layout for new registration screen 2025-10-22 15:27:02 +03:00
mso7nueh
ed612aa019 feat(test): diet, plan, registration 2025-10-21 21:02:17 +03:00
mso7nueh
1f4c450ce7 Merge pull request 'feat(tests): recipe create, fix oauth' (#737) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#737
2025-10-21 11:38:31 +00:00
mso7nueh
1841199291 fix: analyzer 2025-10-21 14:07:12 +03:00
mso7nueh
4c5532ff32 fix: upd ver 2025-10-21 14:00:40 +03:00
mso7nueh
c1cbf1658b fix(oauth): sex=null 2025-10-21 13:59:19 +03:00
misha
2440204294 feat(registration): added base layout for new registration page, and a bit of state managment 2025-10-20 17:55:56 +03:00
mso7nueh
e7ab43c845 feat(tests): recipe create widget test 2025-10-20 12:04:53 +03:00
mso7nueh
b9136a80de feat(tests); recipe create 2025-10-17 17:11:27 +03:00
bb68ea963c Merge pull request 'feat(tests)' (#736) from misha/eatvio_app:tests into main
Reviewed-on: eatvio/eatvio_app#736
2025-10-17 13:14:20 +00:00
misha
228ea7c902 feat(tests): added sign in skip 2025-10-17 16:13:34 +03:00
8c85d1837a Merge pull request 'feat(deepLinks)' (#692) from misha/eatvio_app:deepLinks into main
Reviewed-on: eatvio/eatvio_app#692
2025-10-17 13:04:11 +00:00
misha
1d8f414672 Merge remote-tracking branch 'upstream/main' into deepLinks 2025-10-17 16:03:35 +03:00
49453c8b31 Merge pull request 'feat(pushNotifications)' (#708) from misha/eatvio_app:issue-707 into main
Reviewed-on: eatvio/eatvio_app#708
2025-10-17 12:25:36 +00:00
misha
138e2c1073 Merge remote-tracking branch 'upstream/main' into issue-707 2025-10-17 15:24:21 +03:00
eb39ae5e7c Merge pull request 'feat(diary)' (#705) from misha/eatvio_app:issue-701 into main
Reviewed-on: eatvio/eatvio_app#705
2025-10-17 11:52:09 +00:00
misha
250d9614e3 Merge remote-tracking branch 'upstream/main' into issue-701 2025-10-17 11:50:36 +03:00
b9b06f11a6 Merge pull request 'feat(glossary)' (#711) from misha/eatvio_app:issue-710 into main
Reviewed-on: eatvio/eatvio_app#711
2025-10-16 15:24:56 +00:00
misha
12ca205515 Merge remote-tracking branch 'upstream/main' into issue-710 2025-10-16 18:23:04 +03:00
mso7nueh
d8e7464c37 Merge pull request 'build 80 (android), 81 ios' (#734) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#734
2025-10-16 14:39:17 +00:00
mso7nueh
e0c90bcb21 fix: upd ver to 80 2025-10-16 17:38:23 +03:00
4a96390839 Merge pull request 'doc(readme): README.md update' (#731) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#731
2025-10-16 14:36:34 +00:00
a3b25270ac Merge pull request 'fix: merge conflict' (#733) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#733
2025-10-16 14:34:30 +00:00
misha
c72b3e77c6 fix: merge conflict 2025-10-16 17:33:52 +03:00
35f2c0f30a Merge pull request 'feat(social)' (#732) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#732
2025-10-16 14:20:46 +00:00
misha
9e43be5b4e feat(social): added validation for social media registration 2025-10-16 17:18:45 +03:00
4db5dda3ce doc(readme): README.md update 2025-10-16 14:22:23 +03:00
87d5e0bc23 Merge pull request 'docs and rules added' (#730) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#730
2025-10-16 08:22:11 +00:00
fa3cf297a3 doc(project): project brief docs added 2025-10-16 11:01:37 +03:00
7b08560257 merge upstream 2025-10-16 07:28:06 +00:00
efd6cca87d Merge pull request 'CI\CD fix' (#729) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#729
2025-10-15 11:50:06 +00:00
misha
05bf90a3cf fixed pipline 2025-10-15 14:47:46 +03:00
misha
feee814bbb pod repod update for ci cd 2025-10-15 14:43:21 +03:00
mso7nueh
d871fdc084 Merge pull request 'build 80' (#726) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#726
2025-10-15 11:26:30 +00:00
misha
e865a11d14 fix: fixed proj version 2025-10-15 14:25:27 +03:00
misha
b242eaa027 fix: fixed base url 2025-10-15 14:22:45 +03:00
misha
e073bd3f97 feat(socail): finished registration and ligin via social media 2025-10-15 14:21:43 +03:00
9281213ea1 Merge pull request 'feat(dairy): navigation to diet from diary' (#723) from misha/eatvio_app:issue-720 into main
Reviewed-on: eatvio/eatvio_app#723
2025-10-15 09:38:06 +00:00
misha
f35cdd26a3 feat(diary): fexed wrong day selecting logic 2025-10-06 13:33:12 +03:00
misha
5ee86ddc92 feat(diary): fixed wrong cpfc loading after changing a portion 2025-10-03 15:40:05 +03:00
misha
fceaf3d162 Merge remote-tracking branch 'upstream/main' into issue-701 2025-10-02 14:03:14 +03:00
misha
46841fb502 feat(personalDiets): fixed PlanState widget in order to show personal diet state 2025-10-02 13:49:58 +03:00
misha
8e72350152 Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-02 12:25:40 +03:00
misha
8816b67ff5 Merge remote-tracking branch 'upstream/main' into issue-710 2025-10-02 10:27:45 +03:00
misha
ca3bd7cc0d Merge remote-tracking branch 'upstream/main' into issue-707 2025-10-02 10:21:04 +03:00
misha
782f75d26b feat(diary): fixed cpfc for v2 diary 2025-09-29 09:49:59 +03:00
misha
c88b010cc5 feat(glossary): added pass key for glossary 2025-09-29 09:13:45 +03:00
misha
79dc590beb Merge remote-tracking branch 'upstream/main' into issue-701 2025-09-25 15:14:25 +03:00
misha
517e493b5e feat(pushNotifications): added anonymous token for push notifications 2025-09-24 15:23:01 +03:00
misha
1bc0ab3237 fixed profile back button 2025-09-24 10:01:06 +03:00
misha
a8994eecf4 feat(diary): fixed bugs fod diary with v2 methods 2025-09-22 10:52:29 +03:00
misha
8d3ae15112 feat(diary): added v2 methods for diary 2025-09-18 18:47:07 +03:00
84cdf34859 merge upstream 2025-09-16 08:55:48 +00:00
Username1548
6b7b1f2d18 feat(deepLinks): added diary deep link and improved profile and chat deep links 2025-09-10 15:19:49 +03:00
Username1548
82bc51e45c feat(deepLinks): added chat and profile deeplinks 2025-09-10 08:08:58 +03:00
Username1548
77666aca0e Merge remote-tracking branch 'upstream/main' into deepLinks 2025-09-09 21:24:36 +03:00
Username1548
16430af989 gitignore 2025-09-09 21:24:05 +03:00
Username1548
8c2cd5eedd Merge remote-tracking branch 'upstream/main' into personalDiets 2025-09-05 11:28:51 +03:00
a31f721d81 merge upstream 2025-08-21 12:05:04 +00:00
1b93f5c287 Update README.md 2025-08-20 12:55:06 +00:00
7b8a5d14d7 Update README.md 2025-08-20 11:44:28 +00:00
f94fd6f358 Update README.md 2025-08-20 11:41:55 +00:00
a9ad6aedd0 Update README.md 2025-08-20 11:41:21 +00:00
83dd53351b merge upstream 2025-08-20 11:39:16 +00:00
f269ab59cc merge upstream 2025-08-20 09:50:17 +00:00
Username1548
996f2ecd70 Merge branch 'main' into personalDiets 2025-07-16 17:04:21 +07:00
Username1548
19787f459e feat(personalDiet): fixed wrong path for attach diet method 2025-07-16 17:03:46 +07:00
Username1548
7e64b24a8c added layout for suggestions page 2025-07-15 21:49:47 +07:00
Username1548
a07d085ba7 feat(personalDiets): preapared chats for consultations with personal diets 2025-07-14 20:24:40 +07:00
Username1548
5616bedd25 feat(personalDiets): diets suggestions 2025-07-14 13:44:44 +07:00
Username1548
0a3d9e577d Merge branch 'main' into personalDiets 2025-07-14 12:17:37 +07:00
Username1548
55e3abec99 feat(personal diets): added personal diet option to plans 2025-07-14 12:15:26 +07:00
0d4ec9390d merge upstream 2025-06-30 19:50:42 +00:00
a16e78f804 Merge pull request 'sync main' (#2) from eatvio/eatvio_app:main into main
Reviewed-on: apoloz/eatvio_app#2
2025-06-30 07:23:34 +00:00
c021cdf2c1 Update .gitea/workflows/docker-run-tests.yml 2025-03-13 14:13:19 +00:00
851c300622 Merge pull request 'sync main' (#1) from eatvio/eatvio_app:main into main
Reviewed-on: apoloz/eatvio_app#1
2025-03-13 14:12:52 +00:00
194 changed files with 47178 additions and 2748 deletions

View File

@@ -41,7 +41,7 @@ jobs:
# - name: Build apk
# run: flutter build apk
# some test
# some test 2
# - name: Rename and move apk
# run: |

View File

@@ -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 }}

View File

@@ -140,7 +140,21 @@ jobs:
run: flutter analyze
- name: Test
run: flutter test
run: flutter test --coverage
- name: Test coverage
run: |
awk -F: '
/^LF:/ { total += $2 }
/^LH:/ { covered += $2 }
END {
if (total == 0) {
print "Coverage: 0%"
} else {
printf "Coverage: %.2f%%\n", (covered / total) * 100
}
}
' coverage/lcov.info
- name: Build apk
run: |

View 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.

View 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)

View 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

View 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

View 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

View 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

View File

@@ -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
View File

@@ -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)
```

View File

@@ -19,15 +19,15 @@ def yandexClientId = project.hasProperty('YANDEX_CLIENT_ID') ? project.property(
android {
namespace = "com.eatvioapp.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {

View File

@@ -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.

3
assets/icons/check.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="12" height="9" viewBox="0 0 12 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16229 6.8625L10.5185 0.50625C10.6685 0.35625 10.8435 0.28125 11.0435 0.28125C11.2435 0.28125 11.4185 0.35625 11.5685 0.50625C11.7185 0.65625 11.7935 0.834375 11.7935 1.04063C11.7935 1.24688 11.7185 1.425 11.5685 1.575L4.68729 8.475C4.53729 8.625 4.36229 8.7 4.16229 8.7C3.96229 8.7 3.78729 8.625 3.63729 8.475L0.412289 5.25C0.262289 5.1 0.190414 4.92188 0.196664 4.71563C0.202914 4.50938 0.281039 4.33125 0.431039 4.18125C0.581039 4.03125 0.759164 3.95625 0.965414 3.95625C1.17166 3.95625 1.34979 4.03125 1.49979 4.18125L4.16229 6.8625Z" fill="#16A249"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

View File

@@ -0,0 +1,11 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.2" width="20" height="20" rx="6" fill="#16A249"/>
<g clip-path="url(#clip0_1823_10218)">
<path d="M8.16229 12.8625L14.5185 6.50625C14.6685 6.35625 14.8435 6.28125 15.0435 6.28125C15.2435 6.28125 15.4185 6.35625 15.5685 6.50625C15.7185 6.65625 15.7935 6.83438 15.7935 7.04063C15.7935 7.24688 15.7185 7.425 15.5685 7.575L8.68729 14.475C8.53729 14.625 8.36229 14.7 8.16229 14.7C7.96229 14.7 7.78729 14.625 7.63729 14.475L4.41229 11.25C4.26229 11.1 4.19041 10.9219 4.19666 10.7156C4.20291 10.5094 4.28104 10.3313 4.43104 10.1813C4.58104 10.0313 4.75916 9.95625 4.96541 9.95625C5.17166 9.95625 5.34979 10.0313 5.49979 10.1813L8.16229 12.8625Z" fill="#16A249"/>
</g>
<defs>
<clipPath id="clip0_1823_10218">
<rect width="12" height="9" fill="white" transform="translate(4 6)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1846_342)">
<path d="M5.33333 16C3.85185 16 2.59259 15.4815 1.55556 14.4444C0.518518 13.4074 0 12.1481 0 10.6667V5.33333C0 3.85185 0.518518 2.59259 1.55556 1.55556C2.59259 0.518518 3.85185 0 5.33333 0H10.6667C12.1481 0 13.4074 0.518518 14.4444 1.55556C15.4815 2.59259 16 3.85185 16 5.33333V10.6667C16 12.1481 15.4815 13.4074 14.4444 14.4444C13.4074 15.4815 12.1481 16 10.6667 16H5.33333ZM5.33333 14.2222H10.6667C11.6444 14.2222 12.4815 13.8741 13.1778 13.1778C13.8741 12.4815 14.2222 11.6444 14.2222 10.6667V5.33333C14.2222 4.35556 13.8741 3.51852 13.1778 2.82222C12.4815 2.12593 11.6444 1.77778 10.6667 1.77778H5.33333C4.35556 1.77778 3.51852 2.12593 2.82222 2.82222C2.12593 3.51852 1.77778 4.35556 1.77778 5.33333V10.6667C1.77778 11.6444 2.12593 12.4815 2.82222 13.1778C3.51852 13.8741 4.35556 14.2222 5.33333 14.2222Z" fill="#D22121"/>
<rect x="4.81836" y="10.9749" width="8" height="1" rx="0.5" transform="rotate(-45 4.81836 10.9749)" fill="#D22121"/>
<rect x="5.52539" y="5.31799" width="8" height="1" rx="0.5" transform="rotate(45 5.52539 5.31799)" fill="#D22121"/>
</g>
<defs>
<clipPath id="clip0_1846_342">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

9
assets/icons/logo_2.svg Normal file
View 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

3
assets/icons/sucess.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33333 16C3.85185 16 2.59259 15.4815 1.55556 14.4444C0.518519 13.4074 0 12.1481 0 10.6667V5.33333C0 3.85185 0.518519 2.59259 1.55556 1.55556C2.59259 0.518519 3.85185 0 5.33333 0H10.6667C12.1481 0 13.4074 0.518519 14.4444 1.55556C15.4815 2.59259 16 3.85185 16 5.33333V10.6667C16 12.1481 15.4815 13.4074 14.4444 14.4444C13.4074 15.4815 12.1481 16 10.6667 16H5.33333ZM7.11111 9.06667L5.77778 7.73333C5.61481 7.57037 5.40741 7.48889 5.15556 7.48889C4.9037 7.48889 4.6963 7.57037 4.53333 7.73333C4.37037 7.8963 4.28889 8.1037 4.28889 8.35556C4.28889 8.60741 4.37037 8.81481 4.53333 8.97778L6.48889 10.9333C6.66667 11.1111 6.87407 11.2 7.11111 11.2C7.34815 11.2 7.55556 11.1111 7.73333 10.9333L11.8222 6.84444C11.9852 6.68148 12.0667 6.47407 12.0667 6.22222C12.0667 5.97037 11.9852 5.76296 11.8222 5.6C11.6593 5.43704 11.4519 5.35556 11.2 5.35556C10.9481 5.35556 10.7407 5.43704 10.5778 5.6L7.11111 9.06667ZM5.33333 14.2222H10.6667C11.6444 14.2222 12.4815 13.8741 13.1778 13.1778C13.8741 12.4815 14.2222 11.6444 14.2222 10.6667V5.33333C14.2222 4.35556 13.8741 3.51852 13.1778 2.82222C12.4815 2.12593 11.6444 1.77778 10.6667 1.77778H5.33333C4.35556 1.77778 3.51852 2.12593 2.82222 2.82222C2.12593 3.51852 1.77778 4.35556 1.77778 5.33333V10.6667C1.77778 11.6444 2.12593 12.4815 2.82222 13.1778C3.51852 13.8741 4.35556 14.2222 5.33333 14.2222Z" fill="#16A249"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/images/logo_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

28716
coverage/lcov.info Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
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';
@@ -12,7 +14,7 @@ void main() {
await AppInitializer.initialize(buildType: AppBuildType.tests);
});
testWidgets('Signs in successfully with valid credentials', (WidgetTester tester) async {
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);
@@ -24,14 +26,14 @@ void main() {
expect(passwordFieldFinder, findsOneWidget, reason: 'Password text field should be found.');
expect(signInButtonFinder, findsOneWidget, reason: 'Sign In button should be found.');
await tester.enterText(emailFieldFinder, 'reshetnick.mike+user4@gmail.com');
await tester.enterText(passwordFieldFinder, '1234567890');
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(signInButtonFinder, findsNothing, reason: 'Sign-in button should not be visible after successful login and navigation.');
expect(FeedScreen, findsOne, reason: 'Feed screen should be visible after successful login and navigation.');
});
}

View 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));
}

View File

@@ -5,4 +5,4 @@ Future<void> skipWelcomeScreen(WidgetTester tester) async {
final welcomeScreenButtonFinder = find.byKey(AppKeys.welcomeScreenButton);
await tester.tap(welcomeScreenButtonFinder);
await tester.pumpAndSettle();
}
}

View 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. Обновите документацию в этом файле

View 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'},
],
};
}

View 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);
});
});
}

View File

@@ -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();
});
}

View File

@@ -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.4;
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.4;
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.4;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>74</string>
<string>83</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@@ -1,13 +1,44 @@
import 'package:flutter/material.dart';
class AppKeys {
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
// 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');
}

View 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)]),
),
],
),
],
);
}
}

View File

@@ -1,4 +1,5 @@
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';
@@ -15,22 +16,22 @@ import 'package:flutter_localizations/flutter_localizations.dart';
/// Конфигурация приложения
class AppConfig {
AppConfig({required this.buildType}){
AppConfig({required this.buildType}) {
_baseUrl = switch (buildType) {
AppBuildType.prod => EnvConfig.baseUrl,
AppBuildType.tests => EnvConfig.devBaseUrl,
AppBuildType.tests => EnvConfig.devBaseUrl,
AppBuildType.dev => EnvConfig.devBaseUrl,
};
}
final AppBuildType buildType;
late final String _baseUrl;
late String _baseUrl;
String get baseUrl => _baseUrl;
void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls;
}
/// Список глобальных BlocProvider'ов
List<BlocProvider> get globalBlocProviders => [
BlocProvider<NewMessagesCubit>(create: (context) => sl<NewMessagesCubit>()),
@@ -40,6 +41,7 @@ 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>()),
];
/// Делегаты локализации

View File

@@ -29,23 +29,45 @@ 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';
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,3 @@
class MarketingCubitState {
const MarketingCubitState();
}

View File

@@ -1,3 +1,4 @@
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';
@@ -30,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';
@@ -37,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';
@@ -45,6 +48,7 @@ import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
@@ -73,10 +77,11 @@ 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()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
// Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
@@ -129,7 +134,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()));
@@ -141,5 +146,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()));
}
}

View File

@@ -2,7 +2,9 @@ import 'package:dio/dio.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: sl<AppConfig>().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()));
}
}

View File

@@ -23,12 +23,14 @@ 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';
import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
import 'package:get_it/get_it.dart';
@@ -68,6 +70,8 @@ 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()));
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
// Content repositories
sl.registerLazySingleton<BlogPostRepository>(() => BlogPostRepository(blogPostService: sl()));

View File

@@ -25,12 +25,14 @@ 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';
import 'package:eatvio_app/features/recipe/services/about_recipe_service.dart';
import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
import 'package:eatvio_app/features/registration/services/registration_service.dart';
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
import 'package:eatvio_app/features/settings/services/settings_service.dart';
import 'package:get_it/get_it.dart';
@@ -76,6 +78,8 @@ class ServiceRegistrations {
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
// Content services
sl.registerLazySingleton<CreateRecipeService>(() => CreateRecipeService(sl()));

View File

@@ -29,6 +29,22 @@ class FirebaseApi {
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 {

View File

@@ -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);

View 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
View 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
View File

@@ -0,0 +1 @@
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }

View File

@@ -1,3 +1,5 @@
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';
@@ -7,6 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/pages/scan_qr_code_screen.
import 'package:eatvio_app/features/registration/presentation/pages/registration_screen.dart';
import 'package:eatvio_app/features/registration/presentation/pages/verify_screen.dart';
import 'package:eatvio_app/features/registration/presentation/pages/verify_email_screen.dart';
import 'package:eatvio_app/features/registration_v2/presentation/pages/registration_v2_page.dart';
import 'package:go_router/go_router.dart';
/// Маршруты для аутентификации
@@ -18,8 +21,13 @@ 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(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
GoRoute(
path: RouteConstants.forgetPasswordScreen,
name: 'forgetPasswordScreen',

View File

@@ -2,6 +2,8 @@ import 'package:dio/dio.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';
}
},
),
@@ -139,10 +148,16 @@ 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,
path: '${RouteConstants.subsScreen}/:slug',
name: 'subsScreen',
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug']!, subType: state.extra as SubType),
),
@@ -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,11 @@ 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 +220,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 +244,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,),
),
];
}
}

View File

@@ -3,6 +3,7 @@ class RouteConstants {
// Auth routes
static const String authScreen = '/auth-screen';
static const String registrationScreen = 'registration-screen';
static const String registrationScreenV2 = 'registration-screen-v2';
static const String forgetPasswordScreen = 'forget-password-screen';
static const String scanQRCodeScreen = 'scan-qr-code-screen';
static const String verifyScreen = '/verify-screen';

View File

@@ -10,161 +10,76 @@ class CustomTextStyles {
static TextStyle get headlineSmallMulish => theme.textTheme.headlineSmall!.mulish;
// Title text style
static TextStyle get titleMediumSegoeUI => theme.textTheme.titleMedium!.segoeUI.copyWith(
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumSegoeUI => theme.textTheme.titleMedium!.segoeUI.copyWith(fontWeight: FontWeight.w600);
static TextStyle get bodyMediumBlack900 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black900,
);
static TextStyle get bodyMediumBlack900 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black900);
static TextStyle get bodyMediumBlack90013 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black900,
fontSize: 13,
);
static TextStyle get bodyMediumBlack90013 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black900, fontSize: 13);
static TextStyle get bodyMediumBluegray800 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumBluegray800_1 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800_1 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumBluegray800_2 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800_2 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumWhiteA700 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.whiteA700,
);
static TextStyle get bodyMediumWhiteA700 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.whiteA700);
static TextStyle get bodySmallMulishBlack900 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black900,
fontSize: 12,
);
static TextStyle get bodySmallMulishBlack900 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black900, fontSize: 12);
static TextStyle get bodySmallMulishBluegray800 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray800 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray80012 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray80012 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray80012_1 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray80012_1 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray900 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.blueGray900,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray900 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.blueGray900, fontSize: 12);
static TextStyle get bodySmallMulishGray600 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.gray600,
);
static TextStyle get bodySmallMulishGray600 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.gray600);
static TextStyle get bodySmallMulishPrimary => theme.textTheme.bodySmall!.mulish.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
);
static TextStyle get bodySmallMulishPrimary => theme.textTheme.bodySmall!.mulish.copyWith(color: theme.colorScheme.primary, fontSize: 12);
static TextStyle get bodySmallSegoeUIBlack900 => theme.textTheme.bodySmall!.segoeUI.copyWith(
color: appTheme.black900,
fontSize: 12,
);
static TextStyle get bodySmallSegoeUIBlack900 => theme.textTheme.bodySmall!.segoeUI.copyWith(color: appTheme.black900, fontSize: 12);
// Label text style
static TextStyle get labelLargeGray60003 => theme.textTheme.labelLarge!.copyWith(
color: appTheme.gray60003,
);
static TextStyle get labelLargeGray60003 => theme.textTheme.labelLarge!.copyWith(color: appTheme.gray60003);
static TextStyle get labelLargeWhiteA700 => theme.textTheme.labelLarge!.copyWith(
color: appTheme.whiteA700,
);
static TextStyle get labelLargeWhiteA700 => theme.textTheme.labelLarge!.copyWith(color: appTheme.whiteA700);
// Title text style
static TextStyle get titleMedium16 => theme.textTheme.titleMedium!.copyWith(
fontSize: 16,
);
static TextStyle get titleMedium16 => theme.textTheme.titleMedium!.copyWith(fontSize: 16);
static TextStyle get titleMediumBlack => theme.textTheme.titleMedium!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w900,
);
static TextStyle get titleMediumBlack => theme.textTheme.titleMedium!.copyWith(fontSize: 17, fontWeight: FontWeight.w900);
static TextStyle get titleMediumBlack900 => theme.textTheme.titleMedium!.copyWith(
color: appTheme.black900,
);
static TextStyle get titleMediumBlack900 => theme.textTheme.titleMedium!.copyWith(color: appTheme.black900);
static TextStyle get titleMediumSemiBold => theme.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumSemiBold => theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600);
static TextStyle get titleMediumWhiteA700 => theme.textTheme.titleMedium!.copyWith(
color: appTheme.whiteA700,
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumWhiteA700 => theme.textTheme.titleMedium!.copyWith(color: appTheme.whiteA700, fontWeight: FontWeight.w600);
static TextStyle get titleSmallBlue800 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.blue800,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallBlue800 => theme.textTheme.titleSmall!.copyWith(color: appTheme.blue800, fontWeight: FontWeight.w800);
static TextStyle get titleSmallBluegray800 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
);
static TextStyle get titleSmallBluegray800 => theme.textTheme.titleSmall!.copyWith(color: appTheme.black);
static TextStyle get titleSmallBluegray800Bold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
fontWeight: FontWeight.w700,
);
static TextStyle get titleSmallBluegray800Bold => theme.textTheme.titleSmall!.copyWith(color: appTheme.black, fontWeight: FontWeight.w700);
static TextStyle get titleSmallBluegray800ExtraBold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallBluegray800ExtraBold => theme.textTheme.titleSmall!.copyWith(color: appTheme.black, fontWeight: FontWeight.w800);
static TextStyle get titleSmallBold => theme.textTheme.titleSmall!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
);
static TextStyle get titleSmallBold => theme.textTheme.titleSmall!.copyWith(fontSize: 15, fontWeight: FontWeight.w700);
static TextStyle get titleSmallGreenA70001 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.greenA70001,
);
static TextStyle get titleSmallGreenA70001 => theme.textTheme.titleSmall!.copyWith(color: appTheme.greenA70001);
static TextStyle get titleSmallGreenA70001ExtraBold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.greenA70001,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallGreenA70001ExtraBold => theme.textTheme.titleSmall!.copyWith(color: appTheme.greenA70001, fontWeight: FontWeight.w800);
static TextStyle get titleSmallLightgreen300 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.lightGreen300,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallLightgreen300 => theme.textTheme.titleSmall!.copyWith(color: appTheme.lightGreen300, fontWeight: FontWeight.w800);
static TextStyle get titleSmallRed200 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.red200,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallRed200 => theme.textTheme.titleSmall!.copyWith(color: appTheme.red200, fontWeight: FontWeight.w800);
static TextStyle get titleSmallSecondaryContainer => theme.textTheme.titleSmall!.copyWith(
color: theme.colorScheme.secondaryContainer,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallSecondaryContainer =>
theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer, fontWeight: FontWeight.w800);
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(
color: theme.colorScheme.secondaryContainer,
);
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(
color: theme.colorScheme.onPrimary,
);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
}
extension on TextStyle {
@@ -178,75 +93,32 @@ extension on TextStyle {
}
abstract class TextStyles {
static const headingXLSemibold = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const headingXLSemibold = TextStyle(fontSize: 20, fontWeight: FontWeight.w600, height: 1.255);
static const headingFS40FW600 = TextStyle(
fontSize: 40,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const headingFS40FW600 = TextStyle(fontSize: 40, fontWeight: FontWeight.w600, height: 1.255);
static const textLSemibold = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textLSemibold = TextStyle(fontSize: 18, fontWeight: FontWeight.w600, height: 1.255);
static const textLMedium = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textMSemibold = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textLMedium = TextStyle(fontSize: 18, fontWeight: FontWeight.w500, height: 1.255);
static const textMMedium = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1,
);
static const textMSemibold = TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.255);
static const textMMediumAuto = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textMMedium = TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1);
static const textSSemibold = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textMMediumAuto = TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1.255);
static const textSMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textSSemibold = TextStyle(fontSize: 14, fontWeight: FontWeight.w600, height: 1.255);
static const textFS13FW400 = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.255,
);
static const textSMedium = TextStyle(fontSize: 14, fontWeight: FontWeight.w500, height: 1.255);
static const textXSSemibold = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textFS13FW400 = TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1.255);
static const textXSSemibold = TextStyle(fontSize: 12, fontWeight: FontWeight.w500, height: 1.255);
static const textFS10FW500 = TextStyle(fontSize: 10, fontWeight: FontWeight.w500, height: 1.255);
static const textFS24FW700 = TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.255);
static const textFS22FW600 = TextStyle(fontSize: 22, fontWeight: FontWeight.w600, height: 1.255);
static const textFS10FW500 = TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.255,
);
}

View File

@@ -7,23 +7,17 @@ class ThemeHelper {
// The current app theme
final _appTheme = 'primary';
// A map of custom color themes supported by the app
final Map<String, PrimaryColors> _supportedCustomColor = {
'primary': PrimaryColors(),
};
// A map of custom color themes supported by the app
final Map<String, PrimaryColors> _supportedCustomColor = {'primary': PrimaryColors()};
// A map of color schemes supported by the app
final Map<String, ColorScheme> _supportedColorScheme = {
'primary': ColorSchemes.primaryColorScheme,
};
// A map of color schemes supported by the app
final Map<String, ColorScheme> _supportedColorScheme = {'primary': ColorSchemes.primaryColorScheme};
/// Returns the primary colors for the current theme.
PrimaryColors _getThemeColors() {
//throw exception to notify given theme is not found or not generated by the generator
if (!_supportedCustomColor.containsKey(_appTheme)) {
throw Exception(
"$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner",
);
throw Exception("$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner");
}
//return theme from map
@@ -34,9 +28,7 @@ class ThemeHelper {
ThemeData _getThemeData() {
//throw exception to notify given theme is not found or not generated by the generator
if (!_supportedColorScheme.containsKey(_appTheme)) {
throw Exception(
"$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner",
);
throw Exception("$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner");
}
//return theme from map
@@ -60,17 +52,9 @@ class ThemeHelper {
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.transparent,
side: BorderSide(
color: colorScheme.primary,
width: 1,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
visualDensity: const VisualDensity(
vertical: -4,
horizontal: -4,
),
side: BorderSide(color: colorScheme.primary, width: 1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
visualDensity: const VisualDensity(vertical: -4, horizontal: -4),
padding: EdgeInsets.zero,
),
),
@@ -101,7 +85,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,12 +125,17 @@ class PrimaryColors {
Color get gray60003 => const Color(0XFF707070);
Color get gray60005 => const Color(0xFF6C7C93);
// Green
Color get greenA700 => const Color(0XFF019A3E);
Color get greenA70001 => const Color(0XFF00BE13);
Color get green => const Color(0XFF0BB450);
Color get darkGreen => const Color(0XFF16A249);
Color get transparentGreen => const Color.fromARGB(12, 1, 154, 63);
// LightGreen
Color get lightGreen300 => const Color(0XFFB2DF8A);
@@ -180,6 +169,8 @@ class PrimaryColors {
Color get lightGray0 => const Color(0xFFF6F6F6);
Color get darkGrey => const Color(0xFF5B656F);
Color get lightGray00 => const Color(0xFFE9E9EA);
Color get dottedBorder => const Color(0xFFE6E6E6);
@@ -206,6 +197,8 @@ class PrimaryColors {
Color get cardNotificationsBG => const Color(0xFFA4A4A4);
Color get grey7B => const Color(0xFF7B7B7B);
Color get greyD9 => const Color(0xFFD9D9D9);
}
PrimaryColors get appTheme => ThemeHelper().themeColor();

View File

@@ -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]) {

View File

@@ -3,16 +3,28 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class CustomDropDownButton<T> extends StatelessWidget {
const CustomDropDownButton({super.key, required this.items, required this.onChanged, this.value, this.hint, this.hasError = false});
const CustomDropDownButton({
super.key,
required this.items,
required this.onChanged,
this.value,
this.hint,
this.selectedItemBuilder,
this.hasError = false,
this.isExpanded = false,
});
final T? value;
final List<DropdownMenuItem<T>> items;
final void Function(T?)? onChanged;
final Widget? hint;
final bool hasError;
final List<Widget> Function(BuildContext)? selectedItemBuilder;
final bool isExpanded;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField(
hint: hint,
isExpanded: isExpanded,
icon: SvgPicture.asset('assets/icons/arrow_down2.svg', width: 12, height: 8),
borderRadius: BorderRadius.circular(20),
dropdownColor: appTheme.lightGray0,
@@ -35,6 +47,7 @@ class CustomDropDownButton<T> extends StatelessWidget {
initialValue: value,
items: items,
onChanged: onChanged,
selectedItemBuilder: selectedItemBuilder,
);
}
}

View 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),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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,

View File

@@ -6,9 +6,10 @@ part 'oauth_login_request.g.dart';
@freezed
abstract class OAuthLoginRequest with _$OAuthLoginRequest {
const factory OAuthLoginRequest({
required String provider,
@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);

View File

@@ -6,15 +6,16 @@ part 'oauth_model.g.dart';
@freezed
abstract class OAuthModel with _$OAuthModel {
const factory OAuthModel({
required String provider,
required String id,
@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,
@JsonKey(name: 'date_of_birth') required String dateOfBirth,
required String gender,
required int weight,
required int height,

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -1,31 +1,40 @@
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> {
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository;
OAuthCubit({required this.authRepository}) : super(const OAuthState());
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
this.prefs = prefs;
}
Future<void> getOAuthLogin({required String provider, required String accessToken, required String refreshToken}) async {
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
try {
log('OAuthCubit: Starting OAuth login for provider: $provider');
emit(const OAuthState.loading());
log('OAuthCubit: Emitted loading state');
final tokenModel = await authRepository.getOAuthLogin(provider: provider, accessToken: accessToken, refreshToken: refreshToken);
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));
log('OAuthCubit: Emitted loaded state');
} catch (e) {
log('OAuthCubit: Error occurred: $e');
emit(OAuthState.error(errorMessage: e.toString()));
log('OAuthCubit: Emitted error state');
}
}
Future<void> getOAuthToken({
Future<List<RegistrationValidationErrors>?> signUpViaSocialMedia({
required String provider,
required String accessToken,
required String id,
@@ -38,10 +47,11 @@ class OAuthCubit extends Cubit<OAuthState> {
required String gender,
required int weight,
required int height,
required String clientId,
}) async {
try {
emit(const OAuthState.loading());
final token = await authRepository.getOAuthToken(
final token = await authRepository.signUpViaSocialMedia(
provider: provider,
accessToken: accessToken,
id: id,
@@ -54,10 +64,43 @@ class OAuthCubit extends Cubit<OAuthState> {
gender: gender,
weight: weight,
height: height,
clientId: clientId,
);
TokenStorage.saveTokenInStorage(token);
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} catch (e) {
} 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 }

View File

@@ -1,5 +1,3 @@
// ignore_for_file: unused_element, duplicate_ignore
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/app_keys.dart';
@@ -95,6 +93,138 @@ 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;
@@ -129,7 +259,6 @@ class _AuthScreenState extends State<AuthScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Social auth buttons at the top (outlined)
const Center(child: Text('Авторизация через')),
const SizedBox(height: 12),
Row(
@@ -208,7 +337,7 @@ class _AuthScreenState extends State<AuthScreen> {
children: [
const Text('Нет аккаунта? '),
GestureDetector(
onTap: () => GoRouter.of(context).goNamed('registrationScreen'),
onTap: () => GoRouter.of(context).pushNamed('registrationScreenV2'),
child: Text(
'Зарегистрироваться',
style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline),
@@ -282,7 +411,7 @@ class _AuthScreenState extends State<AuthScreen> {
_isLoading = false;
});
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
@@ -297,8 +426,8 @@ class _AuthScreenState extends State<AuthScreen> {
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
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 {
await showDialog(
@@ -358,7 +487,7 @@ class _AuthScreenState extends State<AuthScreen> {
},
);
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: const Icon(Icons.qr_code_scanner_rounded, color: Colors.white),
),
@@ -405,143 +534,3 @@ class _SocialButton extends StatelessWidget {
);
}
}
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');
// Пробуем сначала авторизоваться через новый API
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;
}
// Пробуем сначала авторизоваться через новый API
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'ok');
}
} 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;
}
// Пробуем сначала авторизоваться через новый API
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'mailru');
}
} 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;
// Пробуем сначала авторизоваться через новый API
if (context.mounted) {
await _tryOAuthLogin(context, authResult, 'yandex');
}
} catch (e) {
log(e.toString());
}
}
/// Пробует авторизоваться через новый OAuth login API
/// Если получает ошибку (включая 422 - пользователь не найден), переходит к регистрации
Future<void> _tryOAuthLogin(BuildContext context, AuthResultEntity authResult, String provider) async {
log('Starting OAuth login for provider: $provider');
try {
// Создаем OAuth cubit
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) {
// Ошибка авторизации (включая 422 - пользователь не найден) - переходим к регистрации
log('OAuth login failed: ${state.errorMessage}');
subscription?.cancel();
AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider);
}
});
// Пробуем авторизоваться
log('Calling getOAuthLogin with provider: $provider');
await oauthCubit.getOAuthLogin(provider: provider, accessToken: authResult.accessToken, refreshToken: authResult.refreshToken ?? '');
// Ждем немного, чтобы состояние успело обновиться
await Future.delayed(const Duration(milliseconds: 100));
} catch (e) {
// В случае любой ошибки (включая 422 - пользователь не найден) переходим к регистрации
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 (_) {}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}

View File

@@ -11,6 +11,7 @@ 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';
@@ -42,6 +43,12 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
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;
@@ -211,8 +218,11 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
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),
@@ -222,13 +232,13 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
controller: phoneController,
keyboardType: TextInputType.number,
inputFormatters: [maskFormatter],
onChanged: (newValue) {
// Дополнительная обработка изменений номера телефона
// Это поможет маске корректно работать при редактировании
},
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,
@@ -243,9 +253,11 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
hintText: '01.01.2000',
controller: dateOfBirthController,
keyboardType: TextInputType.number,
hasError: dateOfBirthError,
inputFormatters: [maskFormatterForBirthday],
onChanged: (newValue) {},
),
if (dateOfBirthError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Некорректный формат даты рождения')],
],
),
),
@@ -271,6 +283,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: MediaQuery.of(context).size.width / 2 - 22,
@@ -280,7 +293,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
children: [
const Text('Вес'),
const SizedBox(height: 4),
CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}),
CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}, hasError: weightError),
if (weightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Вес должен быть от 30 до 300 кг')],
],
),
),
@@ -292,7 +306,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
children: [
const Text('Рост'),
const SizedBox(height: 4),
CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}),
CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}, hasError: heightError),
if (heightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Рост должен быть от 100 до 250 см')],
],
),
),
@@ -361,23 +376,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
),
),
),
// Показываем ошибки если есть
if (state is OAuthStateError) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
state.errorMessage,
style: TextStyle(color: Colors.red.shade700),
textAlign: TextAlign.center,
),
),
],
// Обработка успешной регистрации
if (state is OAuthStateLoaded) ...[
const SizedBox(height: 16),
@@ -438,7 +437,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
);
}
void _handleRegistration(BuildContext context) {
Future<void> _handleRegistration(BuildContext context) async {
final clientID = widget.authResult?.clientId;
// Валидация полей
if (firstNameController.text.isEmpty ||
lastNameController.text.isEmpty ||
@@ -447,26 +447,45 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
dateOfBirthController.text.isEmpty ||
weightController.text.isEmpty ||
heightController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Пожалуйста, заполните все поля')));
showErrorToast('Пожалуйста, заполните все поля');
return;
}
// Парсим числовые значения
int? weight;
int? height;
try {
weight = int.parse(weightController.text);
height = int.parse(heightController.text);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Вес и рост должны быть числами')));
return;
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 регистрацию
context.read<OAuthCubit>().getOAuthToken(
final errors = await context.read<OAuthCubit>().signUpViaSocialMedia(
provider: widget.provider ?? 'social',
accessToken: widget.authResult?.accessToken ?? 'dummy_token',
id: widget.authResult?.userId ?? 'dummy_id',
@@ -479,6 +498,35 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
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('Регистрация успешна');
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -20,27 +19,23 @@ class AuthRepository {
}
}
Future<TokenModel> getTokenBySocial({required String provider, required String accessToken}) async {
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<TokenModel> getOAuthLogin({required String provider, required String accessToken, required String refreshToken}) async {
try {
return await authService.getOAuthLogin(
body: OAuthLoginRequest(provider: provider, accessToken: accessToken, refreshToken: refreshToken),
);
} catch (e) {
rethrow;
}
}
Future<String> getOAuthToken({
Future<String> signUpViaSocialMedia({
required String provider,
required String accessToken,
required String id,
@@ -53,9 +48,10 @@ class AuthRepository {
required String gender,
required int weight,
required int height,
required String clientId,
}) async {
try {
return (await authService.getOAuthToken(
return (await authService.signUpViaSocialMedia(
body: OAuthModel(
provider: provider,
accessToken: accessToken,
@@ -69,6 +65,7 @@ class AuthRepository {
gender: gender == 'М' ? 'male' : 'female',
weight: weight,
height: height,
clientId: clientId,
),
))['token']!
as String;

View File

@@ -8,6 +8,7 @@ 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';
@@ -38,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;
}
@@ -48,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'];
@@ -88,6 +90,7 @@ class SocialAuthFacade {
phone: profile.phone,
gender: GenderEnum.fromVkGender(profile.parsedSex),
birthDate: profile.bDayDt?.toString(),
clientId: clientId,
);
}
@@ -151,6 +154,7 @@ class SocialAuthFacade {
phone: yandexProfileData.defaultPhone.number,
gender: GenderEnum.fromString(yandexProfileData.sex),
birthDate: yandexProfileData.birthday,
clientId: clientId,
);
} catch (e) {
log(e.toString());
@@ -188,7 +192,7 @@ class SocialAuthFacade {
try {
final controller = VkIDController(clID: int.parse(clientIdStr));
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider, scopes: [VkScope.phone, VkScope.email,]);
final authUri = controller.generateAuthorizeLinkWithCodeVerifier(provider: provider, scopes: [VkScope.phone, VkScope.email]);
final codeVerifier = authUri.key;
final authUrl = authUri.value.toString();
final redirectScheme = 'vk$clientIdStr://';
@@ -199,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;
}
@@ -230,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;

View File

@@ -3,7 +3,6 @@ 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';
@@ -15,12 +14,9 @@ abstract class AuthService {
@POST('/api/login')
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?>> getOAuthToken({@Body() required OAuthModel body});
Future<Map<String, Object?>> signUpViaSocialMedia({@Body() required OAuthModel body});
@POST('/api/oauth/login')
Future<TokenModel> getOAuthLogin({@Body() required OAuthLoginRequest body});
Future<TokenModel> signInViaSocialMedia({@Body() required OAuthLoginRequest body});
}

View File

@@ -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);

View File

@@ -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(
@@ -231,10 +232,35 @@ 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),

View File

@@ -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;
}
}

View File

@@ -1,9 +1,11 @@
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/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/app_decoration.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
@@ -24,10 +26,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 +41,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 +52,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
setState(() {
shrinkImage = visible;
enableMdPanel = visible;
});
});
if (widget.feedBlogPostModel != null) {
@@ -62,9 +65,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 +422,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 +440,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 +544,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 +565,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: isLoading
? const Center(child: Loader())
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
child: const Center(
child: Text('Создать', style: TextStyle(color: Colors.white)),
),
),
);
}
GestureDetector _buildAddPhoto() {
return GestureDetector(
onTap: () {
@@ -265,22 +646,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 +662,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 +670,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('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}'),
),
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
),
),
],

View File

@@ -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});
},
),

View File

@@ -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('Отмена')),
),
);
}

View File

@@ -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),
),
),
),
),
],
],
),
);
}
}

View File

@@ -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 }

View File

@@ -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);

View File

@@ -63,25 +63,28 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: widget.consultationsModel.userSlug),
child: Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
),
),
const SizedBox(height: 12),
GestureDetector(

View File

@@ -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}');

View File

@@ -3,6 +3,7 @@ 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';
@@ -47,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,
@@ -55,7 +57,8 @@ class CreateDietCubit extends Cubit<CreateDietState> {
name: diet.name,
duration: diet.duration,
type: type,
imageUrl: '${sl<AppConfig>().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)));
@@ -76,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;
@@ -93,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,
),
);
@@ -125,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) {
@@ -133,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);
@@ -162,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);
@@ -184,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);
@@ -199,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));
}
@@ -213,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));
@@ -268,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>>>{};
@@ -339,7 +302,10 @@ class CreateDietCubit extends Cubit<CreateDietState> {
Future<String?> createDiet() async {
emit(state.copyWith(isDietPublishing: true));
if (!_isFromValid()) return 'validation';
if (!_isFromValid()) {
emit(state.copyWith(isDietPublishing: false));
return 'validation';
}
try {
File? file;
if (state.dietData.imageUrl != null) {

View File

@@ -1,13 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/enums/product_type.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/core/utils/string_extentions.dart';
import 'package:eatvio_app/core/widgets/bookmark_button.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/info_row.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart';
import 'package:eatvio_app/core/widgets/cpfc_container.dart';
@@ -36,20 +31,11 @@ class DietPreviewScreen extends StatefulWidget {
class _DietPreviewScreenState extends State<DietPreviewScreen> {
final ScrollController scrollController = ScrollController();
final textEditingController = TextEditingController();
bool isMoreOptionsVisible = false;
late final Uint8List imageData;
@override
void initState() {
super.initState();
imageData = base64.decode(widget.diet.image);
scrollController.addListener(() {
if (isMoreOptionsVisible) {
setState(() {
isMoreOptionsVisible = false;
});
}
});
}
@override
@@ -58,86 +44,6 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
return Scaffold(
backgroundColor: appTheme.gray100,
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: BookmarkButton(
isFavorite: widget.diet.isFavorite,
onTap: () {},
width: 52,
height: 52,
size: 24,
shape: BoxShape.circle,
color: appTheme.gray50,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: SizedBox(
child: TextFormField(
textCapitalization: TextCapitalization.sentences,
onChanged: (_) {
setState(() {});
},
controller: textEditingController,
maxLines: 5,
minLines: 1,
keyboardType: TextInputType.multiline,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
hintText: s.comment.capitalize(),
hintStyle: TextStyles.textMMedium.copyWith(color: appTheme.lightGray600),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : appTheme.gray50, shape: BoxShape.circle),
height: 32,
width: 32,
child: Icon(Icons.send_rounded, size: 18, color: textEditingController.text.length > 2 ? appTheme.whiteA700 : appTheme.black),
),
),
],
),
],
),
),
),
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -148,15 +54,7 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
actions: [
CustomPopup(
isAuthor: widget.diet.author.authorIsMe,
path: 'diet/${widget.diet.slug}',
complainedContentType: ComplainedContent.diet,
userSlug: widget.diet.author.authorSlug,
),
const SizedBox(width: 16),
],
actions: const [],
),
body: SingleChildScrollView(
controller: scrollController,

View File

@@ -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()),
);
},
),

View File

@@ -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',
},
});
}

View File

@@ -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),
),
),
],
),
],
),
);

View File

@@ -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,7 +31,9 @@ class RecipeCreateScreen extends StatefulWidget {
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
bool _isRecipeBeingPublished = false;
@override
void initState() {
super.initState();
@@ -52,20 +53,16 @@ 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,61 +72,64 @@ 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(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
return _isRecipeBeingPublished
? const Center(child: Loader())
: InkWell(
key: AppKeys.recipeCreateScreenPublishButton,
onTap: () async {
setState(() {
_isRecipeBeingPublished = true;
});
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
setState(() {
_isRecipeBeingPublished = false;
});
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
setState(() {
_isRecipeBeingPublished = false;
});
},
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: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
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: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
),
);
);
},
);
},

View File

@@ -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),
);
}
}

View File

@@ -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);
},
),
],
),
),
),
);

View File

@@ -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(

View File

@@ -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!),
],
],
),

View File

@@ -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),
),
],
),
),
),
);

View File

@@ -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),
],
),
),

View File

@@ -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!),
],
],
),

View 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;
}

View File

@@ -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;
}

View 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);
}

View File

@@ -1,4 +1,6 @@
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'diary_day_model.freezed.dart';
@@ -30,7 +32,7 @@ abstract class DiaryDayWrapperModel with _$DiaryDayWrapperModel {
@freezed
abstract class DiaryDayModel with _$DiaryDayModel {
const factory DiaryDayModel({
@JsonKey(name: "id") required int id,
@JsonKey(name: "id") required int? id,
@JsonKey(name: "date") required String date,
@JsonKey(name: "wakeup") String? wakeup,
@JsonKey(name: "retire") String? retire,
@@ -40,26 +42,36 @@ abstract class DiaryDayModel with _$DiaryDayModel {
@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,
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
}) = _DiaryDayModel;
const DiaryDayModel._();
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
DiaryDayEntity? toEntity() {
if (id == null) {
return null;
}
return DiaryDayEntity(
id: id!,
date: date,
closed: closed,
comment: comment,
typeOfDiary: typeOfDiary,
meals: meals,
chosenPlanId: chosenPlanId,
dayOfChosenDiet: dayOfChosenDiet,
wakeup: wakeup,
retire: retire,
diet: diet,
);
}
DiaryFutureDietDayEntity toDiaryFutureDayEntity() {
return DiaryFutureDietDayEntity(date: date, typeOfDiary: typeOfDiary, meals: meals, dayOfChosenDiet: dayOfChosenDiet, diet: diet);
}
}
@freezed
abstract class DiaryFutureDietDayModel with _$DiaryFutureDietDayModel {
const factory DiaryFutureDietDayModel({
@JsonKey(name: "date") required String date,
@JsonKey(name: "state", fromJson: _parseDayTypeForFutureDay) required TypeOfDiaryModel typeOfDiary,
@JsonKey(name: "meals") required List<MealModel> meals,
@JsonKey(name: "chosen_diet", fromJson: _parseDiet) required DiaryDietModel? diet,
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
}) = _DiaryFutureDietDayModel;
factory DiaryFutureDietDayModel.fromJson(Map<String, dynamic> json) => _$DiaryFutureDietDayModelFromJson(json);
}
DiaryDietModel? _parseDiet(dynamic json) {
DiaryDietModel? parseDiet(dynamic json) {
if (json == null) return null;
return DiaryDietModel.fromJson(json['diet']);
}
@@ -71,10 +83,6 @@ abstract class DiaryDietModel with _$DiaryDietModel {
factory DiaryDietModel.fromJson(Map<String, dynamic> json) => _$DiaryDietModelFromJson(json);
}
TypeOfDiaryModel _parseDayTypeForFutureDay(dynamic json) {
return TypeOfDiaryModel(hasChosenDiet: json['has_chosen_diet'] as bool, hasChosenPlan: json['has_chosen_plan'] as bool, diaryState: DiaryStateEnum.init);
}
@freezed
abstract class MealModel with _$MealModel {
const factory MealModel({
@@ -141,7 +149,7 @@ abstract class TypeOfDiaryModel with _$TypeOfDiaryModel {
const factory TypeOfDiaryModel({
@JsonKey(name: "has_chosen_diet") required bool hasChosenDiet,
@JsonKey(name: "has_chosen_plan") required bool hasChosenPlan,
@JsonKey(name: "last_state") required DiaryStateEnum diaryState,
@JsonKey(name: "last_state") required DiaryStateEnum? diaryState,
}) = _TypeOfDiaryModel;
factory TypeOfDiaryModel.fromJson(Map<String, dynamic> json) => _$TypeOfDiaryModelFromJson(json);

View File

@@ -0,0 +1,11 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'get_day_model.freezed.dart';
part 'get_day_model.g.dart';
@freezed
abstract class GetDayModel with _$GetDayModel {
factory GetDayModel({required String day}) = _GetDayModel;
factory GetDayModel.fromJson(Map<String, dynamic> json) => _$GetDayModelFromJson(json);
}

View File

@@ -1,10 +1,13 @@
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
@@ -20,7 +23,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class DiaryCubit extends Cubit<DiaryState> {
late final DateTime today;
late DateTime selectedDay;
late int selectedMeal;
int selectedMeal = 1;
final DiaryRepository diaryRepository;
final GlossaryRepository glossaryRepository;
final ProfileRepository profileRepository;
@@ -39,19 +42,79 @@ class DiaryCubit extends Cubit<DiaryState> {
today = DateTime.now();
selectedDay = today;
diaryRepository.forceFetchStream.listen((_) {
fetchDiary(true, diaryId);
fetchDiaryV2(needLoading: true, diaryId: diaryId);
});
}
String comment = '';
Future<bool?> setSelectedDay(DateTime day, {bool? forDietOnly}) async {
Future<bool> setSelectedDay(DateTime day) async {
final prevDay = selectedDay;
selectedDay = day;
if (forDietOnly ?? false) {
final result = await fetchDiaryForDietOnly();
return result;
final canSelect = await fetchDiaryV2(needLoading: true);
if (!canSelect) {
selectedDay = prevDay;
}
return canSelect;
}
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId}) async {
final currentState = state;
if (needLoading) {
emit(const DiaryState.loading());
}
if (diaryId != null) {
final diaryDayById = await diaryRepository.getDiaryDay(diaryId);
final diaryEntity = diaryDayById.toEntity();
if (diaryEntity != null) {
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
return true;
}
}
final diaryDay = await getDiary();
if (diaryDay == null) {
emit(const DiaryState.error(errorMessage: 'Diary not found'));
return true;
}
final diaryEntity = diaryDay.toEntity();
if (diaryEntity != null) {
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
return true;
}
if (diaryDay.typeOfDiary.hasChosenDiet == true && diaryDay.typeOfDiary.diaryState == null) {
setDiaryForDietOnly(diaryDay.toDiaryFutureDayEntity());
return true;
}
emit(currentState);
return false;
}
Future<void> updateCurrentDiary() async {
try {
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
final diaryEntity = diaryDay.toEntity();
if (diaryEntity != null) {
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
}
} catch (e) {
log(e.toString());
}
}
Future<DiaryDayModel?> getDiary() async {
try {
await diaryRepository.createDiaryDay(day: selectedDay.toISO8601WithTimeZoneOffset());
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
return diaryDay;
} on DioException catch (e) {
if (e.response?.statusCode == 422) {
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
return diaryDay;
}
}
await fetchDiary(true);
return null;
}
@@ -65,9 +128,7 @@ class DiaryCubit extends Cubit<DiaryState> {
}
}
Future<bool> fetchDiaryForDietOnly() async {
final stringDate = selectedDay.format('yyyy-MM-dd');
final diaryDay = await diaryRepository.getDiaryByDateForDietOnly(stringDate);
Future<bool> setDiaryForDietOnly(DiaryFutureDietDayEntity diaryDay) async {
if (!diaryDay.typeOfDiary.hasChosenDiet) {
return false;
}
@@ -146,22 +207,14 @@ class DiaryCubit extends Cubit<DiaryState> {
return false;
}
Future<void> fetchDiary([bool needLoading = false, int? diaryId, int? currentDay]) async {
Future<void> setDiary({int? diaryId, required DiaryDayEntity diaryDay}) async {
this.diaryId ??= diaryId;
final isConsultantView = this.diaryId != null;
if (needLoading) {
emit(const DiaryState.loading());
selectedMeal = 1;
}
try {
final DiaryDayModel diaryDay;
bool isMoodEnabled = false;
bool isWakeUpAndRetireTimeEnabled = false;
if (diaryId != null) {
diaryDay = await diaryRepository.getDiaryDay(diaryId);
} else {
final stringDate = selectedDay.format('yyyy-MM-dd');
diaryDay = await diaryRepository.getDiaryByDate(stringDate);
final profileSettings = await settingsRepository.getProfileSettings();
isMoodEnabled = profileSettings.isMoodFillable;
isWakeUpAndRetireTimeEnabled = profileSettings.isBedtimeFillable;
@@ -214,7 +267,7 @@ class DiaryCubit extends Cubit<DiaryState> {
Future<void> finishDiaryDay({required int diaryId, required String wakeUp, required String retire}) async {
await diaryRepository.finishDiaryDay(diaryId: diaryId, wakeUp: wakeUp, retire: retire, comment: comment.isEmpty ? null : comment);
await fetchDiary();
await updateCurrentDiary();
}
Future<NutritionDataModel> getNutrition(int index) async {
@@ -238,7 +291,7 @@ class DiaryCubit extends Cubit<DiaryState> {
quantity: quantity,
productNutritionalValueId: productNutritionalValueId,
);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> updateProductInMeal({
@@ -272,27 +325,27 @@ class DiaryCubit extends Cubit<DiaryState> {
portionState: portionState,
);
}
await fetchDiary();
await updateCurrentDiary();
}
Future<void> markEaten({required int diaryId, required int mealId, required int portionId}) async {
await diaryRepository.markEaten(diaryId: diaryId, mealId: mealId, portionId: portionId);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> unmarkEaten({required int diaryId, required int mealId, required int portionId}) async {
await diaryRepository.unmarkEaten(diaryId: diaryId, mealId: mealId, portionId: portionId);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> restore({required int diaryId, required int mealId, required int portionId}) async {
await diaryRepository.restore(diaryId: diaryId, mealId: mealId, portionId: portionId);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> deleteProductFromMeal({required int diaryId, required int mealId, required int portionId}) async {
await diaryRepository.deleteProductFromMeal(diaryId: diaryId, mealId: mealId, portionId: portionId);
await fetchDiary();
await updateCurrentDiary();
}
Future<List<GlossaryModel>> getGlossaryListByQuery(String query) async {
@@ -301,12 +354,12 @@ class DiaryCubit extends Cubit<DiaryState> {
Future<void> uploadPhotos({required List<File> files, required int diaryId}) async {
await diaryRepository.uploadPhotos(files: await ImageCompressUtility.compressFilesList(files), diaryId: diaryId, mealId: selectedMeal);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> deletePhoto({required DiaryPhotoModel photo, required int diaryId}) async {
await diaryRepository.deletePhoto(photo: photo, diaryId: diaryId, mealId: selectedMeal);
await fetchDiary();
await updateCurrentDiary();
}
Future<GlossaryModerationModel?> sendProductToModeration({
@@ -326,30 +379,32 @@ class DiaryCubit extends Cubit<DiaryState> {
Future<void> fillTime({required int diaryId, required String time}) async {
await diaryRepository.fillTime(diaryId: diaryId, mealId: selectedMeal, time: time);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> fillWakeup({required int diaryId, required String time}) async {
await diaryRepository.fillWakeup(diaryId: diaryId, time: time);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> fillRetire({required int diaryId, required String time}) async {
await diaryRepository.fillRetire(diaryId: diaryId, time: time);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> fillSatiety({required int diaryId, required int satiety}) async {
await diaryRepository.fillSatiety(diaryId: diaryId, mealId: selectedMeal, satiety: satiety);
await fetchDiary();
await updateCurrentDiary();
}
Future<void> fillMood({required int diaryId, required int mood}) async {
await diaryRepository.fillMood(diaryId: diaryId, mealId: selectedMeal, mood: mood);
await fetchDiary();
await updateCurrentDiary();
}
void fillComment(String comment) {
this.comment = comment;
}
}
enum DiaryFetchingErrors { canNotCreateFutureDays, dayAlreadyExists }

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,12 +12,10 @@ abstract class DiaryState with _$DiaryState {
const factory DiaryState.loading() = DiaryStateLoading;
const factory DiaryState.error({
required String errorMessage,
}) = DiaryStateError;
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
const factory DiaryState.loaded({
required DiaryDayModel diary,
required DiaryDayEntity diary,
required NutritionDataModel nutrition,
required DateTime selectedDay,
required int selectedMeal,
@@ -32,9 +31,6 @@ abstract class DiaryState with _$DiaryState {
@Default(false) bool isLoadingAddingProductInMeal,
}) = DiaryStateLoaded;
const factory DiaryState.loadedTomorrowWithDiet({
required DiaryFutureDietDayModel diary,
required DateTime selectedDay,
required int selectedMeal,
}) = DiaryStateLoadedTomorrowWithDiet;
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
DiaryStateLoadedTomorrowWithDiet;
}

View File

@@ -35,37 +35,34 @@ class DiaryScreen extends StatelessWidget {
class UserDiaryScreen extends StatelessWidget {
static const path = '/user-diary-screen';
static const name = 'userDiaryScreen';
const UserDiaryScreen({
super.key,
required this.diaryId,
required this.currentPlanDay,
});
const UserDiaryScreen({super.key, required this.diaryId, required this.currentPlanDay});
final int diaryId;
final int currentPlanDay;
@override
Widget build(BuildContext context) {
return DiaryScreenContent(
diaryId: diaryId,
currentPlanDay: currentPlanDay,
);
return DiaryScreenContent(diaryId: diaryId, currentPlanDay: currentPlanDay);
}
}
class DiaryScreenContent extends StatelessWidget {
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay});
final int? diaryId;
final int? currentPlanDay;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(
create: (BuildContext context) => sl<DiaryCubit>()..fetchDiary(true, diaryId, currentPlanDay),
),
BlocProvider(
create: (context) => sl<ConsultantCommentsCubit>(),
),
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
builder: (BuildContext context, DiaryState state) {
@@ -74,16 +71,14 @@ class DiaryScreenContent extends StatelessWidget {
}
if (state is DiaryStateError) {
return Scaffold(
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiary(false, diaryId)),
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
);
}
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: diaryId == null
? AppBar(
toolbarHeight: 0,
)
appBar: widget.diaryId == null
? AppBar(toolbarHeight: 0)
: AppBar(
title: Text(
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
@@ -96,13 +91,8 @@ class DiaryScreenContent extends StatelessWidget {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
if (diaryId == null) const CalendarWidget(),
if (state is DiaryStateLoading) ...[
const SizedBox(
height: 60,
),
const Loader()
],
if (widget.diaryId == null) const CalendarWidget(),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
@@ -114,40 +104,47 @@ class DiaryScreenContent extends StatelessWidget {
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
),
if (state is DiaryStateLoadedTomorrowWithDiet)
const MealWidget(
showMood: false,
showAddButton: false,
),
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
const SizedBox(height: 12),
Builder(builder: (context) {
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
if (state.diary.comment.isNotEmpty) {
return CommentForConsultantWidget(
comment: state.diary.comment,
);
Builder(
builder: (context) {
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
if (state.diary.comment.isNotEmpty) {
return CommentForConsultantWidget(comment: state.diary.comment);
}
if (state.isDiaryCommented || state.isDiaryFinished) {
return const SizedBox.shrink();
}
return const CommentForConsultantWidget();
}
if (state.isDiaryCommented || state.isDiaryFinished) {
return const SizedBox.shrink();
}
return const CommentForConsultantWidget();
}
return const SizedBox.shrink();
}),
return const SizedBox.shrink();
},
),
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FinishDayButtonWidget(
diaryDay: state.diary,
),
child: _isDiaryFinishing
? const Center(child: Loader())
: FinishDayButtonWidget(
onPressed: () async {
setState(() {
_isDiaryFinishing = true;
});
await context.read<DiaryCubit>().finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
setState(() {
_isDiaryFinishing = false;
});
},
),
),
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
const SizedBox(height: 16),
const MainBackgroundBouble(
padding: EdgeInsets.all(16),
child: DayCommentWidget(),
)
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
],
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
const SizedBox(height: 12),
@@ -155,10 +152,7 @@ class DiaryScreenContent extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
s.dayComment,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 12),
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
],

View File

@@ -25,26 +25,30 @@ class _AddProductModalState extends State<AddProductModal> {
final quantityController = TextEditingController();
GlossaryModel? selectedGlossary;
List<GlossaryModel> glossariesList = [];
late final String? _previousProductName;
@override
void initState() {
super.initState();
if (widget.portion != null) {
_previousProductName = widget.portion!.productName;
productNameController.text = widget.portion!.productName;
quantityController.text = '${widget.portion!.quantity}';
selectedGlossary = GlossaryModel(
id: widget.portion!.id,
name: widget.portion!.productName,
grInPortion: widget.portion!.portion == 'gr' ? 0 : 1,
energyOn100Gr: 0,
proteinsOn100Gr: 0,
fatsOn100Gr: 0,
carbohydratesOn100Gr: 0,
energyOnPortion: 0,
proteinsOnPortion: 0,
fatsOnPortion: 0,
carbohydratesOnPortion: 0,
);
final productNutritionalValueId = widget.portion?.productNutritionalValueId;
if (productNutritionalValueId != null) {
selectedGlossary = GlossaryModel(
id: productNutritionalValueId,
name: widget.portion!.productName,
grInPortion: widget.portion!.portion == 'gr' ? 0 : 1,
energyOn100Gr: 0,
proteinsOn100Gr: 0,
fatsOn100Gr: 0,
carbohydratesOn100Gr: 0,
energyOnPortion: 0,
proteinsOnPortion: 0,
fatsOnPortion: 0,
carbohydratesOnPortion: 0,
);
}
}
}
@@ -191,7 +195,7 @@ class _AddProductModalState extends State<AddProductModal> {
);
}
Navigator.of(context).pop();
if (selectedGlossary == null) {
if (selectedGlossary == null && _previousProductName != productNameController.text) {
showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,

View File

@@ -43,46 +43,24 @@ class _CalendarWidgetState extends State<CalendarWidget> {
locale: 'ru_RU',
startingDayOfWeek: StartingDayOfWeek.monday,
calendarFormat: _calendarFormat,
headerStyle: const HeaderStyle(
titleCentered: true,
formatButtonVisible: false,
leftChevronVisible: false,
rightChevronVisible: false,
),
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
onDaySelected: (selectedDay, focusedDay) async {
if (!isSameDay(_selectedDay, selectedDay)) {
final today = DateTime.now();
if (selectedDay.isAfter(today)) {
if ((state is DiaryStateLoaded)) {
final result = await context.read<DiaryCubit>().setSelectedDay(_selectedDay, forDietOnly: true);
if (result == false && context.mounted) {
showErrorDialog(context, title: s.want_to_see_tomorrow, description: s.can_not_see_next_day);
return;
}
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
return;
}
if (state is DiaryStateLoadedTomorrowWithDiet) {
final result = await context.read<DiaryCubit>().setSelectedDay(_selectedDay, forDietOnly: true);
if (result == false && context.mounted) {
showErrorDialog(context, title: s.want_to_see_tomorrow, description: s.can_not_see_next_day);
return;
}
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
return;
}
}
final previousSelectedDay = _selectedDay;
final previousFocusedDay = _selectedDay;
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
context.read<DiaryCubit>().setSelectedDay(_selectedDay);
final canOpenNextDay = await context.read<DiaryCubit>().setSelectedDay(selectedDay);
if (!canOpenNextDay && context.mounted) {
showErrorDialog(context, title: s.want_to_see_tomorrow, description: s.can_not_see_next_day);
setState(() {
_selectedDay = previousSelectedDay;
_focusedDay = previousFocusedDay;
});
return;
}
}
},
selectedDayPredicate: (day) {
@@ -101,26 +79,12 @@ class _CalendarWidgetState extends State<CalendarWidget> {
}
},
calendarStyle: CalendarStyle(
weekendDecoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
defaultDecoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
selectedDecoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
todayTextStyle: const TextStyle(
color: Colors.black,
),
weekendDecoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
defaultDecoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
selectedDecoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
todayTextStyle: const TextStyle(color: Colors.black),
todayDecoration: BoxDecoration(
border: Border.all(
color: appTheme.blueA400,
width: 2,
),
border: Border.all(color: appTheme.blueA400, width: 2),
color: Colors.white,
shape: BoxShape.circle,
),
@@ -152,10 +116,7 @@ class _CalendarWidgetState extends State<CalendarWidget> {
child: Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 8, bottom: 16),
child: Icon(
_calendarFormat == CalendarFormat.month ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded,
color: Colors.grey.shade600,
),
child: Icon(_calendarFormat == CalendarFormat.month ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, color: Colors.grey.shade600),
),
);
}

View File

@@ -1,13 +1,10 @@
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FinishDayButtonWidget extends StatefulWidget {
final DiaryDayModel diaryDay;
final Future<void> Function() onPressed;
const FinishDayButtonWidget({super.key, required this.diaryDay});
const FinishDayButtonWidget({super.key, required this.onPressed});
@override
State<FinishDayButtonWidget> createState() => _FinishDayButtonWidgetState();
@@ -17,11 +14,7 @@ class _FinishDayButtonWidgetState extends State<FinishDayButtonWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => context.read<DiaryCubit>().finishDiaryDay(
diaryId: widget.diaryDay.id,
wakeUp: widget.diaryDay.wakeup.toString(),
retire: widget.diaryDay.retire.toString(),
),
onTap: widget.onPressed,
borderRadius: BorderRadius.circular(20),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 8),

Some files were not shown because too many files have changed in this diff Show More