일정추가 페이지 UI, 하단 네비게이션 수정, 다른 ID에서 다른 ID의 등록된 반려 동물 보이는 버그 수정, 헤더 디자인 변경, 스케쥴 DB 만들다 사망
@ -18,21 +18,6 @@ migration:
|
|||||||
- platform: android
|
- platform: android
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
||||||
- platform: ios
|
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
- platform: linux
|
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
- platform: macos
|
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
- platform: web
|
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
- platform: windows
|
|
||||||
create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
@ -41,5 +26,5 @@ migration:
|
|||||||
#
|
#
|
||||||
# Files that are not part of the templates will be ignored by default.
|
# Files that are not part of the templates will be ignored by default.
|
||||||
unmanaged_files:
|
unmanaged_files:
|
||||||
- "lib/main.dart"
|
- 'lib/main.dart'
|
||||||
- "ios/Runner.xcodeproj/project.pbxproj"
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.daoblock.app
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
3
app/assets/icons/add_icon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M23.2143 14.2857H14.2857V23.2143C14.2857 23.6879 14.0976 24.1421 13.7627 24.477C13.4278 24.8119 12.9736 25 12.5 25C12.0264 25 11.5722 24.8119 11.2373 24.477C10.9024 24.1421 10.7143 23.6879 10.7143 23.2143V14.2857H1.78571C1.31211 14.2857 0.85791 14.0976 0.523024 13.7627C0.188138 13.4278 0 12.9736 0 12.5C0 12.0264 0.188138 11.5722 0.523024 11.2373C0.85791 10.9024 1.31211 10.7143 1.78571 10.7143H10.7143V1.78571C10.7143 1.31211 10.9024 0.85791 11.2373 0.523023C11.5722 0.188137 12.0264 0 12.5 0C12.9736 0 13.4278 0.188137 13.7627 0.523023C14.0976 0.85791 14.2857 1.31211 14.2857 1.78571V10.7143H23.2143C23.6879 10.7143 24.1421 10.9024 24.477 11.2373C24.8119 11.5722 25 12.0264 25 12.5C25 12.9736 24.8119 13.4278 24.477 13.7627C24.1421 14.0976 23.6879 14.2857 23.2143 14.2857Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 904 B |
@ -1,3 +0,0 @@
|
|||||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M17.1 1.9H15.2V0H13.3V1.9H5.7V0H3.8V1.9H1.9C0.85215 1.9 0 2.75215 0 3.8V17.1C0 18.1479 0.85215 19 1.9 19H17.1C18.1479 19 19 18.1479 19 17.1V3.8C19 2.75215 18.1479 1.9 17.1 1.9ZM8.60415 15.5895L5.98595 13.0293L7.31405 11.6707L8.58135 12.9105L11.6745 9.78215L13.0255 11.1179L8.60415 15.5895ZM17.1 7.6H1.89905V3.8H3.8V5.7H5.7V3.8H13.3V5.7H15.2V3.8H17.1V7.6Z" fill="#C8C8C8"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 485 B |
12
app/assets/icons/card_icon.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_94_507)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.18558 5.84867C0.724609 6.75369 0.724609 7.935 0.724609 10.3033V13.6865C0.724609 16.0548 0.724609 17.2389 1.18558 18.1411C1.59247 18.935 2.23843 19.5809 3.03226 19.9878C3.93728 20.4488 5.1186 20.4488 7.48687 20.4488H16.5088C18.8771 20.4488 20.0613 20.4488 20.9635 19.9878C21.7584 19.5825 22.4048 18.9361 22.8101 18.1411C23.2711 17.2361 23.2711 16.0548 23.2711 13.6865V10.3033C23.2711 7.935 23.2711 6.75087 22.8101 5.84867C22.4048 5.0537 21.7584 4.4073 20.9635 4.00198C20.0584 3.54102 18.8771 3.54102 16.5088 3.54102H7.48687C5.1186 3.54102 3.93446 3.54102 3.03226 4.00198C2.23843 4.40887 1.59247 5.05484 1.18558 5.84867ZM16.5511 4.94929H7.52916C6.32106 4.94929 5.49921 4.94929 4.86485 5.00286C4.24741 5.0522 3.93023 5.14383 3.71032 5.2566C3.17985 5.5269 2.74856 5.95819 2.47826 6.48867C2.36548 6.70858 2.27385 7.02435 2.22451 7.6432C2.17236 8.27756 2.17095 9.09517 2.17095 10.3075V13.6907C2.17095 14.8988 2.17236 15.7207 2.22451 16.3551C2.27526 16.9725 2.36689 17.2897 2.47826 17.5096C2.74892 18.0396 3.18028 18.471 3.71032 18.7416C3.93023 18.8544 4.24741 18.946 4.86485 18.9954C5.49921 19.0461 6.31683 19.0475 7.52916 19.0475H16.5511C17.7592 19.0475 18.5811 19.0475 19.2154 18.9954C19.8329 18.9446 20.1501 18.853 20.37 18.7416C20.9 18.471 21.3314 18.0396 21.602 17.5096C21.7148 17.2897 21.8064 16.9725 21.8558 16.3536C21.9079 15.7193 21.9094 14.9017 21.9094 13.6893V10.3061C21.9094 9.0994 21.9079 8.27615 21.8558 7.64179C21.805 7.02576 21.7134 6.70858 21.602 6.48867C21.3317 5.95819 20.9005 5.5269 20.37 5.2566C20.1501 5.14383 19.8329 5.0522 19.2154 5.00286C18.5811 4.9507 17.7635 4.94929 16.5511 4.94929Z" fill="#1F1F1F" stroke="#1F1F1F" stroke-width="0.72"/>
|
||||||
|
<path d="M14.7393 8.25302C14.5403 8.25302 14.3496 8.33204 14.2089 8.47269C14.0683 8.61334 13.9893 8.80411 13.9893 9.00302C13.9893 9.20193 14.0683 9.3927 14.2089 9.53335C14.3496 9.674 14.5403 9.75302 14.7393 9.75302L18.8988 9.75C19.0977 9.75 19.2885 9.67098 19.4292 9.53033C19.5698 9.38968 19.6488 9.19891 19.6488 9C19.6488 8.80109 19.5698 8.61032 19.4292 8.46967C19.2885 8.32902 19.0977 8.25 18.8988 8.25L14.7393 8.25302ZM14.7393 11.253C14.5403 11.253 14.3496 11.332 14.2089 11.4727C14.0683 11.6133 13.9893 11.8041 13.9893 12.003C13.9893 12.2019 14.0683 12.3927 14.2089 12.5333C14.3496 12.674 14.5403 12.753 14.7393 12.753L18.8988 12.75C19.0977 12.75 19.2885 12.671 19.4292 12.5303C19.5698 12.3897 19.6488 12.1989 19.6488 12C19.6488 11.8011 19.5698 11.6103 19.4292 11.4697C19.2885 11.329 19.0977 11.25 18.8988 11.25L14.7393 11.253ZM13.9893 15.003C13.9893 14.8041 14.0683 14.6133 14.2089 14.4727C14.3496 14.332 14.5403 14.253 14.7393 14.253L17.3988 14.25C17.5977 14.25 17.7885 14.329 17.9292 14.4697C18.0698 14.6103 18.1488 14.8011 18.1488 15C18.1488 15.1989 18.0698 15.3897 17.9292 15.5303C17.7885 15.671 17.5977 15.75 17.3988 15.75L14.7393 15.753C14.5403 15.753 14.3496 15.674 14.2089 15.5334C14.0683 15.3927 13.9893 15.2019 13.9893 15.003Z" fill="#1F1F1F"/>
|
||||||
|
<path d="M11.3119 8.65306C11.3119 8.546 11.1823 8.49253 11.1068 8.56842L9.76296 9.91913C9.71638 9.96594 9.71638 10.0416 9.76296 10.0884L10.8185 11.1494C10.8654 11.1965 10.9418 11.1965 10.9887 11.1494L11.2268 10.91C11.2737 10.8628 11.35 10.8628 11.3969 10.91L11.6351 11.1494C11.682 11.1965 11.7583 11.1965 11.8052 11.1494L12.4525 10.4988C12.4991 10.452 12.4991 10.3763 12.4525 10.3295L11.3468 9.21814C11.3244 9.19566 11.3119 9.16523 11.3119 9.13351V8.65306ZM4.86444 10.0893C4.81753 10.0421 4.74122 10.0421 4.69431 10.0893L4.4553 10.3295C4.40873 10.3763 4.40873 10.452 4.4553 10.4988L5.56101 11.6101C5.58338 11.6326 5.59594 11.663 5.59594 11.6947V12.8546C5.59594 12.869 5.59337 12.8832 5.58836 12.8966L5.19524 13.9494C5.19022 13.9628 5.18766 13.977 5.18766 13.9914V15.6289C5.18766 15.6951 5.24138 15.7489 5.30766 15.7489H5.88422C5.95049 15.7489 6.00422 15.6951 6.00422 15.6289V14.5654C6.00422 14.5348 6.0159 14.5054 6.03687 14.4831L7.1283 13.3244C7.15098 13.3003 7.18258 13.2867 7.21565 13.2867L9.55875 13.2867C9.62502 13.2867 9.67875 13.3404 9.67875 13.4067V15.6289C9.67875 15.6951 9.73248 15.7489 9.79875 15.7489H10.3753C10.4416 15.7489 10.4953 15.6951 10.4953 15.6289V11.6947C10.4953 11.663 10.4828 11.6326 10.4604 11.6101L9.35554 10.4996C9.30862 10.4525 9.23231 10.4525 9.1854 10.4996L8.89737 10.7891C8.87485 10.8118 8.84423 10.8245 8.8123 10.8245H5.64582C5.61389 10.8245 5.58328 10.8118 5.56075 10.7891L4.86444 10.0893Z" fill="#1F1F1F"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_94_507">
|
||||||
|
<rect width="24" height="24" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
10
app/assets/icons/dashboard_icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_90_2844)">
|
||||||
|
<path d="M8.98886 4.49474C8.99089 5.08242 8.87243 5.66428 8.64079 6.20439C8.30844 7.01869 7.7419 7.71604 7.01292 8.2081C6.28394 8.70016 5.42531 8.9648 4.54581 8.9685C3.661 8.96847 2.79607 8.70608 2.06039 8.2145C1.3247 7.72291 0.751306 7.02421 0.412707 6.20676C0.0741082 5.3893 -0.0144869 4.48979 0.158124 3.62198C0.330736 2.75417 0.756801 1.95704 1.38244 1.33137C1.79806 0.914426 2.29187 0.583609 2.83559 0.357885C3.3793 0.132161 3.96222 0.0159689 4.55093 0.0159689C5.13964 0.0159689 5.72256 0.132161 6.26627 0.357885C6.80999 0.583609 7.3038 0.914426 7.71942 1.33137C8.54911 2.17653 9.01882 3.31044 9.02981 4.49474H8.98886ZM19.9941 4.49474C19.9965 5.08144 19.8817 5.66271 19.6563 6.20439C19.4104 6.74957 19.063 7.24294 18.6325 7.65811C17.7918 8.49769 16.6522 8.96926 15.464 8.96926C14.2759 8.96926 13.1363 8.49769 12.2955 7.65811C11.4566 6.81912 10.9853 5.68122 10.9853 4.49474C10.9853 3.30825 11.4566 2.17036 12.2955 1.33137C12.7094 0.913182 13.2021 0.581158 13.7451 0.354484C14.288 0.127809 14.8705 0.0109785 15.4589 0.0107422C16.6498 0.0141407 17.7908 0.488964 18.6325 1.33137C19.0581 1.74185 19.3977 2.23298 19.6315 2.77611C19.8653 3.31923 19.9885 3.90347 19.9941 4.49474ZM8.98886 15.5C8.98584 16.0925 8.86036 16.6781 8.62031 17.2199C8.40217 17.7651 8.07467 18.2598 7.65799 18.6736C7.24204 19.0912 6.74733 19.422 6.20256 19.6469C5.65778 19.8719 5.07377 19.9864 4.48439 19.984C3.59958 19.984 2.73465 19.7216 1.99896 19.23C1.26328 18.7384 0.689881 18.0397 0.351282 17.2222C0.0126837 16.4048 -0.0759115 15.5053 0.0966999 14.6375C0.269311 13.7697 0.695377 12.9725 1.32102 12.3469C1.73663 11.9299 2.23045 11.5991 2.77416 11.3734C3.31788 11.1476 3.9008 11.0314 4.48951 11.0314C5.07821 11.0314 5.66113 11.1476 6.20485 11.3734C6.74856 11.5991 7.24238 11.9299 7.65799 12.3469C8.48769 13.192 8.9574 14.3259 8.96838 15.5102L8.98886 15.5ZM19.9941 15.5C19.9921 16.3839 19.7282 17.2475 19.2359 17.9816C18.7435 18.7157 18.0447 19.2876 17.2276 19.6249C16.4106 19.9622 15.5119 20.05 14.6451 19.877C13.7782 19.7041 12.982 19.2782 12.3569 18.6532C11.7319 18.0281 11.306 17.2319 11.1331 16.365C10.9601 15.4982 11.0478 14.5995 11.3852 13.7824C11.7225 12.9654 12.2943 12.2666 13.0285 11.7742C13.7626 11.2819 14.6262 11.018 15.5101 11.016C16.7005 11.0216 17.8407 11.4961 18.6837 12.3366C19.1 12.7514 19.4302 13.2444 19.6551 13.7873C19.88 14.3302 19.9952 14.9123 19.9941 15.5Z" fill="#1F1F1F"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_90_2844">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
10
app/assets/icons/female_icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_90_3991)">
|
||||||
|
<path d="M3.27161 1.72609C1.7867 2.94939 0.84066 4.68533 0.607793 6.61389C0.374926 8.54245 0.880628 10.4536 2.03161 11.9953C4.28629 15.0153 8.41009 15.8914 11.6828 14.069L12.9466 15.6792L12.3794 16.1243C11.8649 16.5282 11.7749 17.2752 12.1787 17.7897C12.5826 18.3042 13.3296 18.3943 13.8441 17.9905L14.4113 17.5453L14.9219 18.1959C15.3257 18.7103 16.0727 18.8005 16.5872 18.3966C17.1018 17.9928 17.1917 17.2457 16.788 16.7313L16.2773 16.0807L16.8446 15.6355C17.3591 15.2316 17.4491 14.4846 17.0452 13.9701C16.6414 13.4556 15.8944 13.3655 15.3799 13.7693L14.8126 14.2146L13.5488 12.6044C16.0346 9.9234 16.2127 5.77191 13.9438 2.88114C12.6861 1.27878 10.8781 0.27529 8.85269 0.0554706C6.8279 -0.164268 4.84425 0.431343 3.27161 1.72609ZM12.1595 4.28159C13.9406 6.55085 13.5435 9.84611 11.2741 11.6273C9.00488 13.4084 5.7097 13.0112 3.92854 10.7419C2.14746 8.4726 2.54461 5.17743 4.81386 3.39634C7.08312 1.61525 10.3784 2.01233 12.1595 4.28159Z" fill="#FF3D9B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_90_3991">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -1,3 +1,11 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M19.7 9.3L10.7 0.3C10.3 -0.1 9.7 -0.1 9.3 0.3L0.3 9.3C-0.1 9.7 -0.1 10.3 0.3 10.7C0.7 11.1 1.3 11.1 1.7 10.7L2 10.4V18C2 19.1 2.9 20 4 20H7V14C7 12.3 8.3 11 10 11C11.7 11 13 12.3 13 14V20H16C17.1 20 18 19.1 18 18V10.4L18.3 10.7C18.5 10.9 18.8 11 19 11C19.2 11 19.5 11 19.7 10.7C20.1 10.3 20.1 9.7 19.7 9.3Z" fill="#C8C8C8"/>
|
<g clip-path="url(#clip0_17806_1279)">
|
||||||
|
<path d="M6.5711 21.9345H17.4157C18.5642 21.9345 19.5414 21.0962 19.7145 19.963C19.9922 18.1596 20.0574 16.3322 19.9065 14.5168H22.0048C22.1583 14.5167 22.309 14.4755 22.4411 14.3973C22.5732 14.3191 22.6819 14.2069 22.7558 14.0724C22.8298 13.9379 22.8662 13.7859 22.8613 13.6325C22.8565 13.4791 22.8105 13.3298 22.7282 13.2002L22.3648 12.6293C20.1821 9.19151 17.4044 6.17002 14.162 3.70647L13.0305 2.84933C12.7323 2.6227 12.368 2.5 11.9934 2.5C11.6188 2.5 11.2545 2.6227 10.9562 2.84933L9.82481 3.70647C6.58218 6.17053 3.80449 9.19261 1.62196 12.631L1.25853 13.2019C1.17625 13.3315 1.13028 13.4808 1.12543 13.6342C1.12058 13.7877 1.15701 13.9396 1.23094 14.0741C1.30486 14.2086 1.41355 14.3208 1.54566 14.399C1.67777 14.4772 1.82845 14.5184 1.98196 14.5185H4.08024C3.93125 16.3346 3.99569 18.1619 4.27224 19.963C4.4471 21.0979 5.42424 21.9345 6.5711 21.9345Z" stroke="#1F1F1F" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.9905 13.8672C13.5694 13.8672 14.85 15.146 14.85 16.7249V21.9363H9.13281V16.7249C9.13281 15.1478 10.4117 13.8672 11.9905 13.8672Z" stroke="#1F1F1F" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_17806_1279">
|
||||||
|
<rect width="24" height="24" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 437 B After Width: | Height: | Size: 1.4 KiB |
10
app/assets/icons/male_icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_90_2278)">
|
||||||
|
<path d="M19.619 0.380858C19.3734 0.135208 19.0469 0 18.6996 0C18.6993 0 18.6998 0 18.6996 0L14.0996 0.00264908C13.7494 0.00278151 13.4204 0.139576 13.1731 0.387477C12.9258 0.635511 12.7899 0.964854 12.7907 1.31499C12.7915 1.66552 12.929 1.9946 13.1778 2.24157C13.4249 2.48682 13.7523 2.62163 14.1 2.62163C14.1024 2.62163 14.1048 2.62163 14.1072 2.62163L15.53 2.61422L13.161 4.98318C11.6618 3.8969 9.82215 3.34071 7.96249 3.41341C5.92102 3.49366 3.98363 4.33032 2.50747 5.76953C1.03079 7.20926 0.146192 9.12454 0.0168124 11.1627C-0.134815 13.5499 0.744757 15.8854 2.42961 17.5704C3.98985 19.1306 6.10747 20.0001 8.30786 20C8.48386 20 8.66064 19.9944 8.8373 19.9833C10.8755 19.8539 12.7907 18.9693 14.2305 17.4925C15.6697 16.0164 16.5065 14.079 16.5866 12.0375C16.6597 10.1773 16.103 8.33819 15.0167 6.839L17.3859 4.47003L17.3784 5.89282C17.3765 6.24335 17.5116 6.57335 17.7584 6.82218C18.0053 7.07088 18.3345 7.20833 18.685 7.20913C18.6859 7.20913 18.687 7.20913 18.6879 7.20913C19.0369 7.20913 19.3652 7.07353 19.6125 6.82695C19.8604 6.57958 19.9971 6.25063 19.9974 5.90036L20 1.30121C20 0.953464 19.8647 0.62664 19.619 0.380858ZM14.0836 11.7035C14.0836 14.8947 11.4874 17.4911 8.29621 17.4911C5.10501 17.4911 2.5088 14.8949 2.5088 11.7035C2.5088 8.51233 5.10501 5.91612 8.29621 5.91612C11.4874 5.91612 14.0836 8.51233 14.0836 11.7035Z" fill="#0D9AFF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_90_2278">
|
||||||
|
<rect width="20" height="20" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@ -1,4 +1,4 @@
|
|||||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M9 10C10.4 10 11.7 9.4 12.5 8.5C13.5 7.5 14 6.3 14 5C14 3.7 13.4 2.3 12.5 1.5C11.5 0.5 10.3 0 9 0C7.7 0 6.3 0.6 5.5 1.5C4.5 2.5 4 3.7 4 5C4 6.3 4.5 7.7 5.5 8.5C6.5 9.5 7.7 10 9 10Z" fill="#C8C8C8"/>
|
<path d="M15.8717 7.07455C15.6993 9.40027 13.9362 11.2975 12.0007 11.2975C10.0651 11.2975 8.29895 9.40071 8.1296 7.07455C7.95364 4.65513 9.66923 2.85156 12.0007 2.85156C14.3321 2.85156 16.0477 4.69912 15.8717 7.07455Z" stroke="#C8C8C8" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<path d="M16.6 13.5C15.7 12.5 14.5 12 13 12H5C3.7 12 2.5 12.5 1.5 13.4C0.5 14.3 0 15.5 0 17V20H18V17C18 15.7 17.5 14.5 16.6 13.5Z" fill="#C8C8C8"/>
|
<path d="M11.993 14.1094C8.16588 14.1094 4.28161 16.2209 3.56282 20.2063C3.47616 20.6867 3.74801 21.1477 4.25081 21.1477H19.7351C20.2383 21.1477 20.5102 20.6867 20.4235 20.2063C19.7043 16.2209 15.82 14.1094 11.993 14.1094Z" stroke="#C8C8C8" stroke-width="2.4" stroke-miterlimit="10"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 459 B After Width: | Height: | Size: 692 B |
3
app/assets/icons/stamp_icon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.1875 7.88281C12.1875 7.20313 12.5469 6.58594 12.9648 6.04688C13.457 5.41016 13.75 4.61328 13.75 3.75C13.75 1.67969 12.0703 0 10 0C7.92969 0 6.25 1.67969 6.25 3.75C6.25 4.61328 6.54297 5.41016 7.03516 6.04688C7.45313 6.58594 7.8125 7.20313 7.8125 7.88281C7.8125 9.05078 6.86328 10 5.69531 10H4.375C1.95703 10 0 11.957 0 14.375C0 15.1914 0.523437 15.8867 1.25 16.1445V18.125C1.25 19.1602 2.08984 20 3.125 20H16.875C17.9102 20 18.75 19.1602 18.75 18.125V16.1445C19.4766 15.8867 20 15.1914 20 14.375C20 11.957 18.043 10 15.625 10H14.3047C13.1367 10 12.1875 9.05078 12.1875 7.88281ZM16.25 16.25V17.5H3.75V16.25H16.25Z" fill="#1F1F1F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 747 B |
BIN
app/assets/img/bad.png
Normal file
|
After Width: | Height: | Size: 708 B |
|
Before Width: | Height: | Size: 703 B |
BIN
app/assets/img/good.png
Normal file
|
After Width: | Height: | Size: 857 B |
|
Before Width: | Height: | Size: 862 B |
@ -1,18 +1,21 @@
|
|||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart'; // Import here
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/splash_screen.dart';
|
import 'screens/splash_screen.dart';
|
||||||
import 'screens/register_complete_screen.dart';
|
import 'screens/register_complete_screen.dart';
|
||||||
import 'utils/log_manager.dart';
|
import 'utils/log_manager.dart';
|
||||||
|
import 'providers/pet_provider.dart';
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await initializeDateFormatting('ko_KR', null); // Add this line
|
await initializeDateFormatting('ko_KR', null);
|
||||||
|
|
||||||
// 글로벌 에러 핸들링
|
// 글로벌 에러 핸들링
|
||||||
FlutterError.onError = (FlutterErrorDetails details) {
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
@ -31,7 +34,13 @@ void main() async {
|
|||||||
log('Firebase initialization failed: $e');
|
log('Firebase initialization failed: $e');
|
||||||
LogManager().addLog('[Firebase Init Error] $e');
|
LogManager().addLog('[Firebase Init Error] $e');
|
||||||
}
|
}
|
||||||
runApp(const RupApp());
|
|
||||||
|
runApp(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [ChangeNotifierProvider(create: (_) => PetProvider())],
|
||||||
|
child: const RupApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class RupApp extends StatelessWidget {
|
class RupApp extends StatelessWidget {
|
||||||
|
|||||||
71
app/lib/models/schedule_model.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class Schedule {
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final DateTime date;
|
||||||
|
final String type; // 'general', 'important'
|
||||||
|
final bool isCompleted;
|
||||||
|
final String title;
|
||||||
|
final String? note;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final int? repeatInterval; // 반복 간격 (1, 2, ...)
|
||||||
|
final String? repeatUnit; // 반복 단위 ('day', 'week', 'month', 'year')
|
||||||
|
final bool isAlarmOn; // 알림 설정 여부
|
||||||
|
final DateTime? alarmTime; // 알림 시간
|
||||||
|
|
||||||
|
Schedule({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.date,
|
||||||
|
required this.type,
|
||||||
|
required this.isCompleted,
|
||||||
|
required this.title,
|
||||||
|
this.note,
|
||||||
|
required this.createdAt,
|
||||||
|
this.repeatInterval,
|
||||||
|
this.repeatUnit,
|
||||||
|
this.isAlarmOn = false,
|
||||||
|
this.alarmTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'petId': petId,
|
||||||
|
'date': Timestamp.fromDate(date),
|
||||||
|
'type': type,
|
||||||
|
'isCompleted': isCompleted,
|
||||||
|
'title': title,
|
||||||
|
'note': note,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
'repeatInterval': repeatInterval,
|
||||||
|
'repeatUnit': repeatUnit,
|
||||||
|
'isAlarmOn': isAlarmOn,
|
||||||
|
'alarmTime': alarmTime != null ? Timestamp.fromDate(alarmTime!) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Schedule.fromMap(Map<String, dynamic> map) {
|
||||||
|
DateTime parseDate(dynamic value) {
|
||||||
|
if (value is Timestamp) return value.toDate();
|
||||||
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Schedule(
|
||||||
|
id: map['id']?.toString() ?? '',
|
||||||
|
petId: map['petId']?.toString() ?? '',
|
||||||
|
date: parseDate(map['date']),
|
||||||
|
type: map['type']?.toString() ?? 'general',
|
||||||
|
isCompleted: map['isCompleted'] == true,
|
||||||
|
title: map['title']?.toString() ?? '',
|
||||||
|
note: map['note']?.toString(),
|
||||||
|
createdAt: parseDate(map['createdAt']),
|
||||||
|
repeatInterval: map['repeatInterval'] as int?,
|
||||||
|
repeatUnit: map['repeatUnit']?.toString(),
|
||||||
|
isAlarmOn: map['isAlarmOn'] == true,
|
||||||
|
alarmTime: map['alarmTime'] != null ? parseDate(map['alarmTime']) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/lib/providers/pet_provider.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/pet_model.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
|
||||||
|
class PetProvider with ChangeNotifier {
|
||||||
|
final ApiService _apiService = ApiService();
|
||||||
|
final AuthService _authService = AuthService();
|
||||||
|
|
||||||
|
List<Pet> _pets = [];
|
||||||
|
Pet? _selectedPet;
|
||||||
|
bool _isLoading = false;
|
||||||
|
int? _userId;
|
||||||
|
|
||||||
|
List<Pet> get pets => _pets;
|
||||||
|
Pet? get selectedPet => _selectedPet;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
int? get userId => _userId;
|
||||||
|
|
||||||
|
// Load user and then pets
|
||||||
|
Future<void> loadUserAndPets() async {
|
||||||
|
_isLoading = true;
|
||||||
|
// notifyListeners(); // Avoid notifying during build if called from init
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userInfo = await _authService.getUserInfo();
|
||||||
|
if (userInfo != null) {
|
||||||
|
_userId = userInfo['id'] is int
|
||||||
|
? userInfo['id']
|
||||||
|
: int.tryParse(userInfo['id'].toString());
|
||||||
|
|
||||||
|
if (_userId != null) {
|
||||||
|
await _fetchPets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading user/pets in provider: $e');
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pets from API
|
||||||
|
Future<void> _fetchPets() async {
|
||||||
|
if (_userId == null) return;
|
||||||
|
try {
|
||||||
|
final petsData = await _apiService.getPets(_userId!);
|
||||||
|
_pets = petsData.map((e) => Pet.fromMap(e)).toList();
|
||||||
|
|
||||||
|
// Set default selected pet if needed
|
||||||
|
if (_pets.isNotEmpty) {
|
||||||
|
if (_selectedPet == null) {
|
||||||
|
_selectedPet = _pets.first;
|
||||||
|
} else {
|
||||||
|
// Verify if currently selected pet still exists (e.g. after update)
|
||||||
|
final exists = _pets.any((p) => p.id == _selectedPet!.id);
|
||||||
|
if (exists) {
|
||||||
|
// Update the object to get latest data
|
||||||
|
_selectedPet = _pets.firstWhere((p) => p.id == _selectedPet!.id);
|
||||||
|
} else {
|
||||||
|
_selectedPet = _pets.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_selectedPet = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error fetching pets: $e');
|
||||||
|
_pets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh data (e.g. after add/edit)
|
||||||
|
Future<void> refreshPets() async {
|
||||||
|
await _fetchPets();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a pet
|
||||||
|
void selectPet(Pet pet) {
|
||||||
|
_selectedPet = pet;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state (e.g. on logout)
|
||||||
|
void clearState() {
|
||||||
|
_userId = null;
|
||||||
|
_pets = [];
|
||||||
|
_selectedPet = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,14 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../models/pet_model.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/firestore_service.dart';
|
import '../models/schedule_model.dart';
|
||||||
|
import '../services/api_service.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/common/pet_selection_header.dart';
|
||||||
|
import 'schedule_form_screen.dart';
|
||||||
|
import '../providers/pet_provider.dart';
|
||||||
|
import 'pet_form_screen.dart';
|
||||||
|
|
||||||
class DailyCareScreen extends StatefulWidget {
|
class DailyCareScreen extends StatefulWidget {
|
||||||
const DailyCareScreen({super.key});
|
const DailyCareScreen({super.key});
|
||||||
@ -15,11 +20,10 @@ class DailyCareScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DailyCareScreenState extends State<DailyCareScreen> {
|
class _DailyCareScreenState extends State<DailyCareScreen> {
|
||||||
final FirestoreService _firestoreService = FirestoreService();
|
|
||||||
DateTime _focusedDay = DateTime.now();
|
DateTime _focusedDay = DateTime.now();
|
||||||
DateTime _selectedDay = DateTime.now();
|
DateTime _selectedDay = DateTime.now();
|
||||||
String? _userId;
|
bool _isStampMode = false;
|
||||||
Pet? _selectedPet;
|
|
||||||
final DraggableScrollableController _sheetController =
|
final DraggableScrollableController _sheetController =
|
||||||
DraggableScrollableController();
|
DraggableScrollableController();
|
||||||
|
|
||||||
@ -29,77 +33,161 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜 정규화 (시간 제거)
|
||||||
DateTime _normalizeDate(DateTime date) {
|
DateTime _normalizeDate(DateTime date) {
|
||||||
return DateTime(date.year, date.month, date.day);
|
return DateTime(date.year, date.month, date.day);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final Map<DateTime, List<String>> _events;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_userId = _firestoreService.getCurrentUserId();
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final provider = context.read<PetProvider>();
|
||||||
final today = _normalizeDate(DateTime.now());
|
if (provider.userId == null) {
|
||||||
_events = {
|
provider.loadUserAndPets();
|
||||||
today: [
|
}
|
||||||
'flower',
|
});
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
'incomplete',
|
|
||||||
'flower',
|
|
||||||
'important',
|
|
||||||
'general',
|
|
||||||
'general',
|
|
||||||
], // 오늘: 9개 (3x3 테스트)
|
|
||||||
today.subtract(const Duration(days: 1)): [
|
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
], // 어제: 완료3
|
|
||||||
today.subtract(const Duration(days: 2)): ['general'], // 2일전: 일반1
|
|
||||||
today.subtract(const Duration(days: 3)): [
|
|
||||||
'incomplete',
|
|
||||||
'flower',
|
|
||||||
], // 3일전: 실패1, 완료1
|
|
||||||
today.subtract(const Duration(days: 5)): [
|
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
'flower',
|
|
||||||
], // 5일전: 완료3
|
|
||||||
today.subtract(const Duration(days: 7)): [
|
|
||||||
'incomplete',
|
|
||||||
'incomplete',
|
|
||||||
], // 7일전: 실패2
|
|
||||||
today.subtract(const Duration(days: 10)): [
|
|
||||||
'important',
|
|
||||||
'general',
|
|
||||||
], // 10일전: 중요1, 일반1
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _getEventsForDay(DateTime day) {
|
List<Schedule> _getSchedulesForDay(
|
||||||
return _events[_normalizeDate(day)] ?? [];
|
DateTime day,
|
||||||
|
Map<DateTime, List<Schedule>> scheduleMap,
|
||||||
|
) {
|
||||||
|
return scheduleMap[_normalizeDate(day)] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Variables
|
||||||
|
List<Schedule> _schedules = [];
|
||||||
|
bool _isLoadingSchedules = false;
|
||||||
|
String? _lastPetId;
|
||||||
|
DateTime? _lastFocusedMonth;
|
||||||
|
final ApiService _apiService = ApiService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
_fetchSchedulesIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchSchedulesIfNeeded() async {
|
||||||
|
final petProvider = context.read<PetProvider>();
|
||||||
|
final selectedPet = petProvider.selectedPet;
|
||||||
|
|
||||||
|
if (selectedPet == null) {
|
||||||
|
setState(() {
|
||||||
|
_schedules = [];
|
||||||
|
_lastPetId = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldUpdate = false;
|
||||||
|
// Check if Pet Changed
|
||||||
|
if (_lastPetId != selectedPet.id) {
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
// Check if Month Changed (only year/month matters)
|
||||||
|
if (_lastFocusedMonth == null ||
|
||||||
|
_lastFocusedMonth!.year != _focusedDay.year ||
|
||||||
|
_lastFocusedMonth!.month != _focusedDay.month) {
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
_lastPetId = selectedPet.id;
|
||||||
|
_lastFocusedMonth = _focusedDay;
|
||||||
|
await _fetchSchedules(selectedPet.id, _focusedDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchSchedules(String petId, DateTime date) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingSchedules = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await _apiService.getSchedules(
|
||||||
|
petId: petId,
|
||||||
|
year: date.year,
|
||||||
|
month: date.month,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<Schedule> fetchedSchedules = data
|
||||||
|
.map((item) => Schedule.fromMap(item))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_schedules = fetchedSchedules;
|
||||||
|
_isLoadingSchedules = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error fetching schedules: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingSchedules = false;
|
||||||
|
// Optional: Show error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMonthChanged(DateTime focusedDay) {
|
||||||
|
setState(() {
|
||||||
|
_focusedDay = focusedDay;
|
||||||
|
});
|
||||||
|
// Trigger fetch manually since state updated
|
||||||
|
final petProvider = context.read<PetProvider>();
|
||||||
|
if (petProvider.selectedPet != null) {
|
||||||
|
_fetchSchedules(petProvider.selectedPet!.id, focusedDay);
|
||||||
|
_lastFocusedMonth = focusedDay; // Update cache key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final petProvider = context.watch<PetProvider>();
|
||||||
|
final selectedPet = petProvider.selectedPet;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(),
|
_buildHeader(petProvider),
|
||||||
SizedBox(height: 30.h), // Equal spacing 1
|
SizedBox(height: 10.h),
|
||||||
_buildCustomCalendarHeader(), // Custom Header
|
_buildCustomCalendarHeader(),
|
||||||
SizedBox(height: 30.h), // Equal spacing 2
|
SizedBox(height: 30.h),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||||
child: _buildCalendar(),
|
child: selectedPet == null
|
||||||
|
? const Center(child: Text("반려동물을 선택해주세요"))
|
||||||
|
: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
if (_isLoadingSchedules && _schedules.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 처리: List<Schedule> -> Map<DateTime, List<Schedule>>
|
||||||
|
Map<DateTime, List<Schedule>> scheduleMap = {};
|
||||||
|
for (var schedule in _schedules) {
|
||||||
|
final date = _normalizeDate(schedule.date);
|
||||||
|
if (scheduleMap[date] == null) {
|
||||||
|
scheduleMap[date] = [];
|
||||||
|
}
|
||||||
|
scheduleMap[date]!.add(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildCalendar(scheduleMap);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -111,76 +199,63 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
heroTag: 'daily_care_fab',
|
heroTag: 'daily_care_fab',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// 일정 추가 로직 (추후 구현)
|
if (petProvider.selectedPet == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('일정 추가 기능은 준비 중입니다.')));
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
ScheduleFormScreen(selectedDate: _selectedDay),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
backgroundColor: AppColors.highlight,
|
backgroundColor: AppColors.highlight,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/add_icon.svg',
|
||||||
|
width: 24.w,
|
||||||
|
height: 24.w,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader(PetProvider petProvider) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(left: 20.w, right: 20.w, top: 10.h, bottom: 0),
|
padding: EdgeInsets.only(left: 10.w, right: 20.w, top: 6.h, bottom: 0),
|
||||||
child: StreamBuilder<List<Pet>>(
|
child: Builder(
|
||||||
stream: _userId != null
|
builder: (context) {
|
||||||
? _firestoreService.getPets(_userId!)
|
final pets = petProvider.pets;
|
||||||
: const Stream.empty(),
|
final selectedPet = petProvider.selectedPet;
|
||||||
builder: (context, snapshot) {
|
final isLoading = petProvider.isLoading;
|
||||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
|
||||||
|
if (isLoading) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final pets = snapshot.data!;
|
return PetSelectionHeader(
|
||||||
if (_selectedPet == null ||
|
pets: pets,
|
||||||
!pets.any((p) => p.id == _selectedPet!.id)) {
|
selectedPet: selectedPet,
|
||||||
_selectedPet = pets.first;
|
onPetSelected: (value) {
|
||||||
}
|
context.read<PetProvider>().selectPet(value);
|
||||||
|
},
|
||||||
return Row(
|
onAddPetPressed: () {
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
Navigator.push(
|
||||||
children: [
|
context,
|
||||||
InkWell(
|
MaterialPageRoute(builder: (context) => const PetFormScreen()),
|
||||||
onTap: () {},
|
).then((value) {
|
||||||
child: Row(
|
if (value == true) {
|
||||||
children: [
|
context.read<PetProvider>().refreshPets();
|
||||||
CircleAvatar(
|
}
|
||||||
radius: 20.r,
|
});
|
||||||
backgroundColor: Colors.grey[200],
|
},
|
||||||
backgroundImage: _selectedPet!.profileImageUrl != null
|
|
||||||
? NetworkImage(_selectedPet!.profileImageUrl!)
|
|
||||||
: null,
|
|
||||||
child: _selectedPet!.profileImageUrl == null
|
|
||||||
? SvgPicture.asset(
|
|
||||||
'assets/icons/profile_icon.svg',
|
|
||||||
width: 20.w,
|
|
||||||
colorFilter: ColorFilter.mode(
|
|
||||||
Colors.grey[400]!,
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
SizedBox(width: 8.w),
|
|
||||||
Text(
|
|
||||||
_selectedPet!.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 18.sp,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(Icons.keyboard_arrow_down, size: 24.w),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -189,9 +264,7 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
|
|
||||||
Widget _buildCustomCalendarHeader() {
|
Widget _buildCustomCalendarHeader() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||||
horizontal: 20.w,
|
|
||||||
), // Removed vertical padding
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
@ -200,16 +273,13 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
_onMonthChanged(
|
||||||
_focusedDay = DateTime(
|
DateTime(_focusedDay.year, _focusedDay.month - 1),
|
||||||
_focusedDay.year,
|
);
|
||||||
_focusedDay.month - 1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
'assets/icons/left_icon.svg',
|
'assets/icons/left_icon.svg',
|
||||||
width: 12.w, // Changed to 12px
|
width: 12.w,
|
||||||
height: 12.w,
|
height: 12.w,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -225,16 +295,13 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
SizedBox(width: 30.w),
|
SizedBox(width: 30.w),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
_onMonthChanged(
|
||||||
_focusedDay = DateTime(
|
DateTime(_focusedDay.year, _focusedDay.month + 1),
|
||||||
_focusedDay.year,
|
);
|
||||||
_focusedDay.month + 1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
'assets/icons/right_icon.svg',
|
'assets/icons/right_icon.svg',
|
||||||
width: 12.w, // Changed to 12px
|
width: 12.w,
|
||||||
height: 12.w,
|
height: 12.w,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -242,14 +309,23 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: InkWell(
|
||||||
width: 40.w,
|
onTap: () {
|
||||||
height: 40.w,
|
setState(() {
|
||||||
decoration: BoxDecoration(
|
_isStampMode = !_isStampMode;
|
||||||
color: Colors.black,
|
});
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
},
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(4.w),
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
_isStampMode
|
||||||
|
? 'assets/icons/dashboard_icon.svg'
|
||||||
|
: 'assets/icons/stamp_icon.svg',
|
||||||
|
width: 24.w,
|
||||||
|
height: 24.w,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.download, color: Colors.white, size: 20),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -257,26 +333,43 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendar() {
|
Widget _buildCalendar(Map<DateTime, List<Schedule>> scheduleMap) {
|
||||||
return TableCalendar(
|
return TableCalendar<Schedule>(
|
||||||
shouldFillViewport: false,
|
shouldFillViewport: false,
|
||||||
locale: 'ko_KR',
|
locale: 'ko_KR',
|
||||||
rowHeight: 85
|
rowHeight: 85.h,
|
||||||
.h, // Increased to fit 3 rows of icons (approx 40px + 32px top offset)
|
|
||||||
daysOfWeekHeight: 30.h,
|
daysOfWeekHeight: 30.h,
|
||||||
firstDay: DateTime.utc(2020, 1, 1),
|
firstDay: DateTime.utc(2020, 1, 1),
|
||||||
lastDay: DateTime.utc(2030, 12, 31),
|
lastDay: DateTime.utc(2030, 12, 31),
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
|
||||||
headerVisible: false, // Hide default header
|
headerVisible: false,
|
||||||
onDaySelected: (selectedDay, focusedDay) {
|
onDaySelected: (selectedDay, focusedDay) {
|
||||||
setState(() {
|
// Update selection
|
||||||
_selectedDay = selectedDay;
|
if (!isSameDay(_selectedDay, selectedDay)) {
|
||||||
_focusedDay = focusedDay;
|
setState(() {
|
||||||
});
|
_selectedDay = selectedDay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update focused month if changed (triggers stream update)
|
||||||
|
if (focusedDay.month != _focusedDay.month ||
|
||||||
|
focusedDay.year != _focusedDay.year) {
|
||||||
|
_onMonthChanged(focusedDay);
|
||||||
|
} else {
|
||||||
|
// Just update focused day references without stream update
|
||||||
|
if (!isSameDay(_focusedDay, focusedDay)) {
|
||||||
|
setState(() {
|
||||||
|
_focusedDay = focusedDay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onPageChanged: (focusedDay) {
|
onPageChanged: (focusedDay) {
|
||||||
_focusedDay = focusedDay;
|
if (focusedDay.month != _focusedDay.month ||
|
||||||
|
focusedDay.year != _focusedDay.year) {
|
||||||
|
_onMonthChanged(focusedDay);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
calendarStyle: CalendarStyle(
|
calendarStyle: CalendarStyle(
|
||||||
defaultTextStyle: TextStyle(
|
defaultTextStyle: TextStyle(
|
||||||
@ -306,24 +399,40 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
selectedTextStyle: const TextStyle(
|
selectedTextStyle: const TextStyle(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
),
|
||||||
outsideDaysVisible: true, // Show outside days
|
outsideDaysVisible: true,
|
||||||
),
|
),
|
||||||
eventLoader: _getEventsForDay, // 이벤트 로더
|
eventLoader: (day) => _getSchedulesForDay(day, scheduleMap),
|
||||||
calendarBuilders: CalendarBuilders(
|
calendarBuilders: CalendarBuilders(
|
||||||
// 마커 커스텀 빌더
|
|
||||||
markerBuilder: (context, day, events) {
|
markerBuilder: (context, day, events) {
|
||||||
if (events.isEmpty) return null;
|
if (events.isEmpty) return null;
|
||||||
|
|
||||||
// 외부 날짜인지 확인 (투명도 적용을 위해)
|
|
||||||
// 주의: _focusedDay는 페이지가 바뀔 때만 업데이트되므로,
|
|
||||||
// 달력의 현재 페이지 월과 day의 월을 비교해야 함.
|
|
||||||
// 하지만 markerBuilder는 페이징 중에 다시 빌드될 수 있음.
|
|
||||||
bool isOutside = day.month != _focusedDay.month;
|
bool isOutside = day.month != _focusedDay.month;
|
||||||
double opacity = isOutside ? 0.5 : 1.0;
|
double opacity = isOutside ? 0.3 : 1.0;
|
||||||
|
|
||||||
|
if (_isStampMode) {
|
||||||
|
int total = events.length;
|
||||||
|
int successCount = events.where((s) => s.isCompleted).length;
|
||||||
|
bool isGood = (successCount / total) >= 0.5;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: 40.h,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: opacity,
|
||||||
|
child: Image.asset(
|
||||||
|
isGood ? 'assets/img/good.png' : 'assets/img/bad.png',
|
||||||
|
width: 24.w,
|
||||||
|
height: 24.w,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 이벤트가 있으면 아이콘 표시 (최대 9개 등 제한 가능)
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
top: 32.h,
|
top: 32.h,
|
||||||
left: 0,
|
left: 0,
|
||||||
@ -332,28 +441,25 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
opacity: opacity,
|
opacity: opacity,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width:
|
width: 40.w,
|
||||||
40.w, // Exact width: 12*3 + 2*2 = 40. Centers perfectly.
|
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
alignment: WrapAlignment.start, // Left aligned
|
alignment: WrapAlignment.start,
|
||||||
spacing: 2.w, // Horizontal spacing
|
spacing: 2.w,
|
||||||
runSpacing: 2.h, // Vertical spacing
|
runSpacing: 2.h,
|
||||||
children: events.take(9).map((event) {
|
children: events.take(9).map((schedule) {
|
||||||
String iconPath =
|
String iconPath =
|
||||||
'assets/icons/general_schedule_icon.svg';
|
'assets/icons/general_schedule_icon.svg';
|
||||||
switch (event) {
|
|
||||||
case 'flower':
|
// 상태/타입에 따른 아이콘 결정
|
||||||
iconPath = 'assets/icons/flower_icon.svg';
|
if (schedule.isCompleted) {
|
||||||
break;
|
iconPath = 'assets/icons/flower_icon.svg';
|
||||||
case 'incomplete':
|
} else {
|
||||||
iconPath = 'assets/icons/incomplete_icon.svg';
|
if (schedule.type == 'important') {
|
||||||
break;
|
|
||||||
case 'important':
|
|
||||||
iconPath = 'assets/icons/important_schedule_icon.svg';
|
iconPath = 'assets/icons/important_schedule_icon.svg';
|
||||||
break;
|
} else {
|
||||||
case 'general':
|
// 일반 일정 미완료
|
||||||
iconPath = 'assets/icons/general_schedule_icon.svg';
|
iconPath = 'assets/icons/incomplete_icon.svg';
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SvgPicture.asset(
|
return SvgPicture.asset(
|
||||||
@ -368,16 +474,12 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// 요일 헤더 커스텀
|
|
||||||
dowBuilder: (context, day) {
|
dowBuilder: (context, day) {
|
||||||
final text = DateFormat.E('ko_KR').format(day);
|
final text = DateFormat.E('ko_KR').format(day);
|
||||||
// 요일 헤더는 '현재 달' 개념이 모호하므로(항상 보임) 기본 색상 사용
|
|
||||||
Color color = const Color(0xFF939393);
|
Color color = const Color(0xFF939393);
|
||||||
if (day.weekday == DateTime.sunday)
|
if (day.weekday == DateTime.sunday) color = const Color(0xFFFF3F3F);
|
||||||
color = const Color(0xFFFF3F3F); // 일요일 #FF3F3F로 복귀
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
// Removed bottom border to avoid double border with first row's top border
|
|
||||||
padding: EdgeInsets.only(bottom: 10.h),
|
padding: EdgeInsets.only(bottom: 10.h),
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -391,113 +493,59 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// 외부 날짜 (지난달/다음달) 커스텀
|
|
||||||
outsideBuilder: (context, day, focusedDay) {
|
outsideBuilder: (context, day, focusedDay) {
|
||||||
// 기본 색상에서 투명도 50% 적용
|
Color color = const Color(0xFF1F1F1F).withOpacity(0.5);
|
||||||
Color color = const Color(0xFF1F1F1F).withOpacity(0.5); // 평일 50%
|
|
||||||
if (day.weekday == DateTime.sunday) {
|
if (day.weekday == DateTime.sunday) {
|
||||||
color = const Color(0xFFFF3F3F).withOpacity(0.5); // 일요일 50%
|
color = const Color(0xFFFF3F3F).withOpacity(0.5);
|
||||||
}
|
}
|
||||||
|
return _buildDayCell(day, color);
|
||||||
return Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: Color(0xFFE0E0E0),
|
|
||||||
width: 1,
|
|
||||||
), // Changed to top
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.only(top: 8.h),
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
child: Text(
|
|
||||||
day.day.toString().padLeft(2, '0'),
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 15.sp,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
// 날짜 셀 커스텀
|
|
||||||
defaultBuilder: (context, day, focusedDay) {
|
defaultBuilder: (context, day, focusedDay) {
|
||||||
// 현재 달 날짜 색상
|
|
||||||
Color color = const Color(0xFF1F1F1F);
|
Color color = const Color(0xFF1F1F1F);
|
||||||
if (day.weekday == DateTime.sunday) {
|
if (day.weekday == DateTime.sunday) {
|
||||||
color = const Color(0xFFFF3F3F);
|
color = const Color(0xFFFF3F3F);
|
||||||
}
|
}
|
||||||
|
return _buildDayCell(day, color);
|
||||||
return Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: Color(0xFFE0E0E0),
|
|
||||||
width: 1,
|
|
||||||
), // Changed to top
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.only(top: 8.h), // Move closer to top line
|
|
||||||
alignment: Alignment.topCenter, // Align to top
|
|
||||||
child: Text(
|
|
||||||
day.day.toString().padLeft(2, '0'),
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 15.sp,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
// 오늘/선택 날짜 커스텀
|
|
||||||
prioritizedBuilder: (context, day, focusedDay) {
|
prioritizedBuilder: (context, day, focusedDay) {
|
||||||
final isToday = isSameDay(day, DateTime.now());
|
final isToday = isSameDay(day, DateTime.now());
|
||||||
final isSelected = isSameDay(day, _selectedDay);
|
final isSelected = isSameDay(day, _selectedDay);
|
||||||
|
|
||||||
// 우선순위가 없는 날짜는 null을 반환하여 default/outsideBuilder가 실행되도록 함
|
|
||||||
if (!isToday && !isSelected) return null;
|
if (!isToday && !isSelected) return null;
|
||||||
|
|
||||||
final isSunday = day.weekday == DateTime.sunday;
|
final isSunday = day.weekday == DateTime.sunday;
|
||||||
|
Color textColor = const Color(0xFF1F1F1F);
|
||||||
// 텍스트 색상 결정
|
|
||||||
Color textColor = const Color(0xFF1F1F1F); // 기본 검정
|
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
textColor = const Color(0xFFFF9500); // 오늘: #FF9500 (유지)
|
textColor = const Color(0xFFFF9500);
|
||||||
} else if (isSunday) {
|
} else if (isSunday) {
|
||||||
textColor = const Color(0xFFFF3F3F); // 일요일: #FF3F3F
|
textColor = const Color(0xFFFF3F3F);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배경 색상/데코레이션 결정 (선택된 경우)
|
|
||||||
BoxDecoration? innerDecoration;
|
BoxDecoration? innerDecoration;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
innerDecoration = BoxDecoration(
|
innerDecoration = BoxDecoration(
|
||||||
color: const Color(0xFFFFEDBC), // 선택 배경: #FFEDBC
|
color: const Color(0xFFFFEDBC),
|
||||||
borderRadius: BorderRadius.circular(6.r), // 모서리 덜 둥글게 (6)
|
borderRadius: BorderRadius.circular(6.r),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
// 바깥 컨테이너: 행 구분선 담당
|
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: Color(0xFFE0E0E0), width: 1),
|
top: BorderSide(color: Color(0xFFE0E0E0), width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
// 내부 컨테이너: 선택 배경 담당 (가로 여백 제거로 넓게)
|
|
||||||
margin: EdgeInsets.symmetric(vertical: 2.h),
|
margin: EdgeInsets.symmetric(vertical: 2.h),
|
||||||
decoration: innerDecoration,
|
decoration: innerDecoration,
|
||||||
alignment: Alignment.topCenter, // 상단 정렬
|
alignment: Alignment.topCenter,
|
||||||
padding: EdgeInsets.only(top: 6.h), // 텍스트 위 여백
|
padding: EdgeInsets.only(top: 6.h),
|
||||||
child: Text(
|
child: Text(
|
||||||
day.day.toString().padLeft(2, '0'),
|
day.day.toString().padLeft(2, '0'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 15.sp,
|
fontSize: 15.sp,
|
||||||
fontWeight: FontWeight.bold, // 오늘/선택은 강조
|
fontWeight: FontWeight.bold,
|
||||||
decoration: isSelected ? null : null,
|
|
||||||
color: textColor,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -508,9 +556,30 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDayCell(DateTime day, Color textColor) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: Color(0xFFE0E0E0), width: 1)),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(top: 8.h),
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Text(
|
||||||
|
day.day.toString().padLeft(2, '0'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBottomSheet() {
|
Widget _buildBottomSheet() {
|
||||||
|
// 임시로 비워둠 (실제 리스트 구현 시 채울 예정)
|
||||||
|
// 현재는 일정 추가만 구현
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
controller: _sheetController, // 컨트롤러 연결
|
controller: _sheetController,
|
||||||
initialChildSize: 0.08,
|
initialChildSize: 0.08,
|
||||||
minChildSize: 0.08,
|
minChildSize: 0.08,
|
||||||
maxChildSize: 0.8,
|
maxChildSize: 0.8,
|
||||||
@ -522,9 +591,9 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05), // Lighter shadow
|
color: Colors.black.withOpacity(0.05),
|
||||||
blurRadius: 5, // Tighter blur
|
blurRadius: 5,
|
||||||
offset: const Offset(0, -3), // Closer offset
|
offset: const Offset(0, -3),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -534,10 +603,8 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 헤더 (터치 영역)
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// 현재 높이에 따라 토글
|
|
||||||
if (_sheetController.size > 0.4) {
|
if (_sheetController.size > 0.4) {
|
||||||
_sheetController.animateTo(
|
_sheetController.animateTo(
|
||||||
0.08,
|
0.08,
|
||||||
@ -553,31 +620,14 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.transparent, // 터치 영역 확장
|
color: Colors.transparent,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AnimatedBuilder(
|
Icon(
|
||||||
animation: _sheetController,
|
Icons.keyboard_arrow_up_rounded,
|
||||||
builder: (context, child) {
|
color: Colors.grey[400],
|
||||||
// 0.08 ~ 0.8 사이 값에서 회전 각도 계산
|
size: 24.w,
|
||||||
// 0.4 이상이면 완전히 회전하도록 설정하거나, 비례해서 회전
|
|
||||||
// 간단하게 0.4 기준으로 상태 판단
|
|
||||||
final double angle =
|
|
||||||
_sheetController.isAttached &&
|
|
||||||
_sheetController.size > 0.4
|
|
||||||
? 3.14159
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
return Transform.rotate(
|
|
||||||
angle: angle,
|
|
||||||
child: Icon(
|
|
||||||
Icons.keyboard_arrow_up_rounded,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
size: 24.w,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
SizedBox(height: 20.h),
|
SizedBox(height: 20.h),
|
||||||
],
|
],
|
||||||
@ -587,7 +637,7 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'중요 일정 1',
|
'일정 리스트 준비 중...', // TODO: Implement list
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 14.sp,
|
fontSize: 14.sp,
|
||||||
@ -595,7 +645,7 @@ class _DailyCareScreenState extends State<DailyCareScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 50.h), // 스크롤 테스트용 여백
|
SizedBox(height: 50.h),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'pet_form_screen.dart';
|
import 'pet_form_screen.dart';
|
||||||
import '../services/api_service.dart';
|
|
||||||
import '../services/auth_service.dart';
|
|
||||||
import '../models/pet_model.dart';
|
import '../models/pet_model.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../widgets/home/pet_profile_card.dart';
|
import '../widgets/home/pet_profile_card.dart';
|
||||||
|
import '../widgets/common/pet_selection_header.dart';
|
||||||
|
import '../providers/pet_provider.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@ -16,124 +17,68 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
final ApiService _apiService = ApiService();
|
|
||||||
final AuthService _authService = AuthService();
|
|
||||||
int? _userId;
|
|
||||||
Pet? _selectedPet;
|
|
||||||
Future<List<Pet>>? _petsFuture;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadUserAndPets();
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
}
|
final provider = context.read<PetProvider>();
|
||||||
|
if (provider.userId == null) {
|
||||||
Future<void> _loadUserAndPets() async {
|
provider.loadUserAndPets();
|
||||||
final userInfo = await _authService.getUserInfo();
|
}
|
||||||
if (userInfo != null) {
|
});
|
||||||
setState(() {
|
|
||||||
_userId = userInfo['id'] is int
|
|
||||||
? userInfo['id']
|
|
||||||
: int.tryParse(userInfo['id'].toString());
|
|
||||||
_petsFuture = _fetchPets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Pet>> _fetchPets() async {
|
|
||||||
if (_userId == null) return [];
|
|
||||||
try {
|
|
||||||
final petsData = await _apiService.getPets(_userId!);
|
|
||||||
return petsData.map((e) => Pet.fromMap(e)).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error loading pets: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refreshPets() {
|
|
||||||
if (_userId != null) {
|
|
||||||
setState(() {
|
|
||||||
_petsFuture = _fetchPets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_userId == null) {
|
final petProvider = context.watch<PetProvider>();
|
||||||
|
final pets = petProvider.pets;
|
||||||
|
final displayPet = petProvider.selectedPet;
|
||||||
|
final isLoading = petProvider.isLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check login
|
||||||
|
if (petProvider.userId == null && !isLoading) {
|
||||||
return const Scaffold(body: Center(child: Text('로그인이 필요합니다.')));
|
return const Scaffold(body: Center(child: Text('로그인이 필요합니다.')));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: FutureBuilder<List<Pet>>(
|
child: Builder(
|
||||||
future: _petsFuture,
|
builder: (context) {
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Center(child: Text('오류가 발생했습니다: ${snapshot.error}'));
|
|
||||||
}
|
|
||||||
|
|
||||||
final pets = snapshot.data ?? [];
|
|
||||||
|
|
||||||
// 등록된 반려동물이 없을 때: 기존 UI 유지 (등록 버튼 강조)
|
|
||||||
if (pets.isEmpty) {
|
if (pets.isEmpty) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.only(
|
||||||
horizontal: 20.w,
|
left: 20.w,
|
||||||
vertical: 20.h,
|
right: 20.w,
|
||||||
|
top: 6.h,
|
||||||
|
bottom: 20.h,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: PetSelectionHeader(
|
||||||
color: Colors.transparent,
|
pets: [],
|
||||||
child: InkWell(
|
selectedPet: null,
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
onPetSelected: (_) {},
|
||||||
onTap: () {
|
onAddPetPressed: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const PetFormScreen(),
|
builder: (context) => const PetFormScreen(),
|
||||||
),
|
|
||||||
).then((value) {
|
|
||||||
if (value == true) _refreshPets();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
top: 4.h,
|
|
||||||
bottom: 4.h,
|
|
||||||
right: 12.w,
|
|
||||||
), // Added right padding
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Image.asset(
|
|
||||||
'assets/img/profile.png',
|
|
||||||
width: 40.w,
|
|
||||||
height: 40.h,
|
|
||||||
),
|
|
||||||
SizedBox(width: 6.w), // Reduced spacing
|
|
||||||
Text(
|
|
||||||
'반려동물 등록 +',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 15.sp,
|
|
||||||
letterSpacing: 0.45.sp,
|
|
||||||
color: const Color(0xFF1f1f1f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
).then((value) {
|
||||||
),
|
if (value == true) {
|
||||||
|
context.read<PetProvider>().refreshPets();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -154,172 +99,37 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 등록된 반려동물이 있을 때
|
// 등록된 반려동물이 있을 때
|
||||||
// 선택된 펫이 없거나 리스트에 없으면 첫 번째 펫 선택
|
// displayPet derived from provider (already defined above)
|
||||||
// 등록된 반려동물이 있을 때
|
final currentPet = displayPet ?? pets.first;
|
||||||
Pet displayPet;
|
|
||||||
|
|
||||||
// 선택된 펫이 없거나 리스트에 없으면 첫 번째 펫 선택 (State 변경 없이 화면 표시만 처리)
|
|
||||||
if (_selectedPet != null &&
|
|
||||||
pets.any((p) => p.id == _selectedPet!.id)) {
|
|
||||||
displayPet = pets.firstWhere((p) => p.id == _selectedPet!.id);
|
|
||||||
} else {
|
|
||||||
displayPet = pets.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.only(
|
||||||
horizontal: 20.w,
|
left: 10.w,
|
||||||
vertical: 20.h,
|
right: 20.w,
|
||||||
|
top: 6.h,
|
||||||
|
bottom: 0,
|
||||||
),
|
),
|
||||||
child: PopupMenuButton<dynamic>(
|
child: PetSelectionHeader(
|
||||||
offset: Offset(0, 50.h), // 헤더 바로 아래에 위치하도록 조정
|
pets: pets,
|
||||||
elevation: 3,
|
selectedPet: displayPet, // displayPet comes from provider
|
||||||
shape: RoundedRectangleBorder(
|
onPetSelected: (value) {
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
context.read<PetProvider>().selectPet(value);
|
||||||
),
|
|
||||||
color: Colors.white,
|
|
||||||
surfaceTintColor: Colors.white,
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value is Pet) {
|
|
||||||
setState(() {
|
|
||||||
_selectedPet = value;
|
|
||||||
});
|
|
||||||
} else if (value == 'add_pet') {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const PetFormScreen(),
|
|
||||||
),
|
|
||||||
).then((value) {
|
|
||||||
if (value == true) _refreshPets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
onAddPetPressed: () {
|
||||||
return [
|
Navigator.push(
|
||||||
...pets.map(
|
context,
|
||||||
(pet) => PopupMenuItem<Pet>(
|
MaterialPageRoute(
|
||||||
value: pet,
|
builder: (context) => const PetFormScreen(),
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 16.r,
|
|
||||||
backgroundColor: Colors.grey[200],
|
|
||||||
backgroundImage: pet.profileImageUrl != null
|
|
||||||
? NetworkImage(pet.profileImageUrl!)
|
|
||||||
: null,
|
|
||||||
child: pet.profileImageUrl == null
|
|
||||||
? SvgPicture.asset(
|
|
||||||
'assets/icons/profile_icon.svg',
|
|
||||||
width: 16.w,
|
|
||||||
colorFilter: ColorFilter.mode(
|
|
||||||
Colors.grey[400]!,
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
Text(
|
|
||||||
pet.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 14.sp,
|
|
||||||
fontWeight: pet.id == displayPet.id
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
color: pet.id == displayPet.id
|
|
||||||
? AppColors.highlight
|
|
||||||
: Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (pet.id == displayPet.id) ...[
|
|
||||||
const Spacer(),
|
|
||||||
const Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: AppColors.highlight,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
).then((value) {
|
||||||
PopupMenuItem<String>(
|
if (value == true) {
|
||||||
value: 'add_pet',
|
context.read<PetProvider>().refreshPets();
|
||||||
child: Row(
|
}
|
||||||
children: [
|
});
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(4.w),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[100],
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.add,
|
|
||||||
size: 16.w,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
Text(
|
|
||||||
'반려동물 추가하기',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 14.sp,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// 프로필 이미지
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 20.r,
|
|
||||||
backgroundColor: Colors.grey[200],
|
|
||||||
backgroundImage: displayPet.profileImageUrl != null
|
|
||||||
? NetworkImage(displayPet.profileImageUrl!)
|
|
||||||
: null,
|
|
||||||
child: displayPet.profileImageUrl == null
|
|
||||||
? SvgPicture.asset(
|
|
||||||
'assets/icons/profile_icon.svg',
|
|
||||||
width: 20.w,
|
|
||||||
colorFilter: ColorFilter.mode(
|
|
||||||
Colors.grey[400]!,
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
SizedBox(width: 10.w),
|
|
||||||
// 이름
|
|
||||||
Text(
|
|
||||||
displayPet.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18.sp,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 4.w),
|
|
||||||
// 드롭다운 화살표
|
|
||||||
Icon(
|
|
||||||
Icons.keyboard_arrow_down,
|
|
||||||
size: 24.w,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -327,8 +137,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
PetProfileCard(
|
PetProfileCard(
|
||||||
pet: displayPet,
|
pet: currentPet, // Use safe currentPet
|
||||||
onPetUpdated: _refreshPets,
|
onPetUpdated: () {
|
||||||
|
context.read<PetProvider>().refreshPets();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenutil
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
|
import '../widgets/common/custom_app_bar.dart';
|
||||||
|
|
||||||
class IdentityVerificationScreen extends StatelessWidget {
|
class IdentityVerificationScreen extends StatelessWidget {
|
||||||
const IdentityVerificationScreen({super.key});
|
const IdentityVerificationScreen({super.key});
|
||||||
@ -9,24 +10,7 @@ class IdentityVerificationScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: const CustomAppBar(title: '본인 인증'),
|
||||||
backgroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 20.w),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'본인 인증',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15.sp,
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: EdgeInsets.all(20.0.w),
|
padding: EdgeInsets.all(20.0.w),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenuti
|
|||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
import 'terms_agreement_screen.dart'; // Import TermsAgreementScreen
|
import 'terms_agreement_screen.dart'; // Import TermsAgreementScreen
|
||||||
|
import '../widgets/common/custom_app_bar.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
@ -68,28 +69,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Scaffold(
|
Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: const CustomAppBar(title: '간편 로그인'),
|
||||||
backgroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.arrow_back_ios,
|
|
||||||
color: Colors.black,
|
|
||||||
size: 16.w, // Responsive size
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'간편 로그인',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15.sp, // Responsive font size
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
20.w,
|
20.w,
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import 'daily_care_screen.dart';
|
|||||||
import 'mungnyangz_screen.dart';
|
import 'mungnyangz_screen.dart';
|
||||||
import 'my_info_screen.dart';
|
import 'my_info_screen.dart';
|
||||||
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
|
|
||||||
@ -22,7 +20,7 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
final List<Widget> _screens = [
|
final List<Widget> _screens = [
|
||||||
const HomeScreen(),
|
const HomeScreen(),
|
||||||
const DailyCareScreen(),
|
const DailyCareScreen(),
|
||||||
const MungNyangzScreen(), // 멍냥즈 (샵 대신)
|
const MungNyangzScreen(),
|
||||||
const MyInfoScreen(),
|
const MyInfoScreen(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -39,9 +37,7 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
width: 24.w,
|
width: 24.w,
|
||||||
height: 24.h,
|
height: 24.h,
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(
|
||||||
_selectedIndex == index
|
_selectedIndex == index ? Colors.black : Colors.grey,
|
||||||
? AppColors.highlight
|
|
||||||
: AppColors.inactive, // 선택됨: 강조색, 안됨: 비활성화색
|
|
||||||
BlendMode.srcIn,
|
BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -64,59 +60,51 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
),
|
),
|
||||||
child: BottomNavigationBar(
|
child: BottomNavigationBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0, // Remove default shadow since we have a border
|
elevation: 0,
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
onTap: _onItemTapped,
|
onTap: _onItemTapped,
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
selectedItemColor: AppColors.highlight,
|
selectedItemColor: Colors.black,
|
||||||
unselectedItemColor: AppColors.inactive,
|
unselectedItemColor: Colors.grey,
|
||||||
selectedLabelStyle: TextStyle(
|
selectedLabelStyle: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 12.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w500, // Medium
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
),
|
),
|
||||||
unselectedLabelStyle: TextStyle(
|
unselectedLabelStyle: TextStyle(
|
||||||
fontFamily: 'SCDream',
|
fontFamily: 'SCDream',
|
||||||
fontSize: 12.sp,
|
fontSize: 14.sp,
|
||||||
fontWeight: FontWeight.w400, // Regular
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey,
|
||||||
),
|
),
|
||||||
showUnselectedLabels: true,
|
showUnselectedLabels: true,
|
||||||
items: [
|
items: [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 10.h),
|
padding: EdgeInsets.only(bottom: 8.h),
|
||||||
child: _buildSvgIcon('assets/icons/home_icon.svg', 0),
|
child: _buildSvgIcon('assets/icons/home_icon.svg', 0),
|
||||||
),
|
),
|
||||||
label: '홈',
|
label: '홈',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 10.h),
|
padding: EdgeInsets.only(bottom: 8.h),
|
||||||
child: _buildSvgIcon('assets/icons/calendar_icon.svg', 1),
|
child: _buildSvgIcon('assets/icons/calendar_icon.svg', 1),
|
||||||
),
|
),
|
||||||
label: '데일리케어',
|
label: '데일리케어',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 10.h),
|
padding: EdgeInsets.only(bottom: 8.h),
|
||||||
child: Image.asset(
|
child: _buildSvgIcon('assets/icons/card_icon.svg', 2),
|
||||||
_selectedIndex == 2
|
|
||||||
? 'assets/img/catdog_on.png'
|
|
||||||
: 'assets/img/catdog_off.png',
|
|
||||||
width: 29.w,
|
|
||||||
height: 26.h,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
label: '멍냥즈',
|
label: '펫 명함함',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: EdgeInsets.only(bottom: 10.h),
|
padding: EdgeInsets.only(bottom: 8.h),
|
||||||
child: _buildSvgIcon(
|
child: _buildSvgIcon('assets/icons/my_icon.svg', 3),
|
||||||
'assets/icons/my_icon.svg',
|
|
||||||
3,
|
|
||||||
), // Index adjusted
|
|
||||||
),
|
),
|
||||||
label: '내 정보',
|
label: '내 정보',
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
import '../providers/pet_provider.dart';
|
||||||
import 'welcome_screen.dart';
|
import 'welcome_screen.dart';
|
||||||
import 'notice_screen.dart';
|
import 'notice_screen.dart';
|
||||||
import '../data/terms_data.dart';
|
import '../data/terms_data.dart';
|
||||||
@ -46,6 +48,10 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleLogout() async {
|
Future<void> _handleLogout() async {
|
||||||
|
// Clear Provider State before logging out (to prevent data leak)
|
||||||
|
if (mounted) {
|
||||||
|
context.read<PetProvider>().clearState();
|
||||||
|
}
|
||||||
await _authService.signOut();
|
await _authService.signOut();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
@ -76,6 +82,9 @@ class _MyInfoScreenState extends State<MyInfoScreen> {
|
|||||||
|
|
||||||
if (confirm == true) {
|
if (confirm == true) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Clear Provider State before withdrawal
|
||||||
|
context.read<PetProvider>().clearState();
|
||||||
|
|
||||||
final success = await _authService.withdrawAccount();
|
final success = await _authService.withdrawAccount();
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import '../models/pet_model.dart';
|
|||||||
import '../services/api_service.dart';
|
import '../services/api_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../widgets/pet_registration/input_formatters.dart';
|
import '../widgets/pet_registration/input_formatters.dart';
|
||||||
|
import '../widgets/common/custom_app_bar.dart';
|
||||||
|
|
||||||
class PetFormScreen extends StatefulWidget {
|
class PetFormScreen extends StatefulWidget {
|
||||||
final Pet? petToEdit;
|
final Pet? petToEdit;
|
||||||
@ -387,25 +388,20 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> finalDiseases = List.from(_selectedDiseases);
|
List<String> finalDiseases = _processSelectionForSubmit(
|
||||||
if (finalDiseases.contains('기타') && _otherDiseaseText.isNotEmpty) {
|
_selectedDiseases,
|
||||||
finalDiseases.remove('기타');
|
_otherDiseaseText,
|
||||||
finalDiseases.add('기타($_otherDiseaseText)');
|
);
|
||||||
}
|
|
||||||
|
|
||||||
List<String> finalPastDiseases = List.from(_selectedPastDiseases);
|
List<String> finalPastDiseases = _processSelectionForSubmit(
|
||||||
if (finalPastDiseases.contains('기타') &&
|
_selectedPastDiseases,
|
||||||
_otherPastDiseaseText.isNotEmpty) {
|
_otherPastDiseaseText,
|
||||||
finalPastDiseases.remove('기타');
|
);
|
||||||
finalPastDiseases.add('기타($_otherPastDiseaseText)');
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> finalHealthConcerns = List.from(_selectedHealthConcerns);
|
List<String> finalHealthConcerns = _processSelectionForSubmit(
|
||||||
if (finalHealthConcerns.contains('기타') &&
|
_selectedHealthConcerns,
|
||||||
_otherHealthConcernText.isNotEmpty) {
|
_otherHealthConcernText,
|
||||||
finalHealthConcerns.remove('기타');
|
);
|
||||||
finalHealthConcerns.add('기타($_otherHealthConcernText)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수정 모드
|
// 수정 모드
|
||||||
if (widget.petToEdit != null) {
|
if (widget.petToEdit != null) {
|
||||||
@ -823,72 +819,24 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
child: showInput
|
child: showInput
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.all(20.w),
|
padding: EdgeInsets.all(20.w),
|
||||||
child: Column(
|
child: _buildDirectInputContent(
|
||||||
children: [
|
title: '반려동물의 종을 직접 입력해주세요.',
|
||||||
Text(
|
hintText: '예: 미어캣, 라쿤 등',
|
||||||
'반려동물의 종을 직접 입력해주세요.',
|
controller: speciesInputController,
|
||||||
style: TextStyle(
|
bottomInset: bottomInset,
|
||||||
fontFamily: 'SCDream',
|
onComplete: () {
|
||||||
fontSize: 16.sp,
|
if (speciesInputController.text.isNotEmpty) {
|
||||||
color: Colors.black87,
|
setState(() {
|
||||||
),
|
_speciesController.text =
|
||||||
),
|
speciesInputController.text;
|
||||||
SizedBox(height: 20.h),
|
// 직접 입력 시 카테고리 정보 초기화
|
||||||
TextField(
|
_currentMajorCategory = null;
|
||||||
controller: speciesInputController,
|
_currentMinorCategory = null;
|
||||||
autofocus: true,
|
_breedController.clear();
|
||||||
decoration: const InputDecoration(
|
});
|
||||||
hintText: '예: 미어캣, 라쿤 등',
|
Navigator.pop(context);
|
||||||
border: OutlineInputBorder(),
|
}
|
||||||
focusedBorder: OutlineInputBorder(
|
},
|
||||||
borderSide: BorderSide(
|
|
||||||
color: AppColors.highlight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52.h,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (speciesInputController
|
|
||||||
.text
|
|
||||||
.isNotEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_speciesController.text =
|
|
||||||
speciesInputController.text;
|
|
||||||
// 직접 입력 시 카테고리 정보 초기화 (품종 선택 불가 또는 직접 입력)
|
|
||||||
_currentMajorCategory = null;
|
|
||||||
_currentMinorCategory = null;
|
|
||||||
_breedController.clear();
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.highlight,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
12.r,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'완료',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 16.sp,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: bottomInset),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: (selectedMajor == null
|
: (selectedMajor == null
|
||||||
@ -1116,68 +1064,20 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
child: showInput
|
child: showInput
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.all(20.w),
|
padding: EdgeInsets.all(20.w),
|
||||||
child: Column(
|
child: _buildDirectInputContent(
|
||||||
children: [
|
title: '반려동물의 품종을 직접 입력해주세요.',
|
||||||
Text(
|
hintText: '예: 믹스, 시고르자브종 등',
|
||||||
'반려동물의 품종을 직접 입력해주세요.',
|
controller: manualInputController,
|
||||||
style: TextStyle(
|
bottomInset: bottomInset,
|
||||||
fontFamily: 'SCDream',
|
onComplete: () {
|
||||||
fontSize: 16.sp,
|
if (manualInputController.text.isNotEmpty) {
|
||||||
color: Colors.black87,
|
setState(() {
|
||||||
),
|
_breedController.text =
|
||||||
),
|
manualInputController.text;
|
||||||
SizedBox(height: 20.h),
|
});
|
||||||
TextField(
|
Navigator.pop(context);
|
||||||
controller: manualInputController,
|
}
|
||||||
autofocus: true,
|
},
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '예: 믹스, 시고르자브종 등',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: AppColors.highlight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52.h,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (manualInputController
|
|
||||||
.text
|
|
||||||
.isNotEmpty) {
|
|
||||||
setState(() {
|
|
||||||
_breedController.text =
|
|
||||||
manualInputController.text;
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.highlight,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
12.r,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'완료',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 16.sp,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: bottomInset),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
@ -1311,32 +1211,13 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 20.h),
|
SizedBox(height: 20.h),
|
||||||
Text(
|
Expanded(
|
||||||
'반려동물의 품종을 직접 입력해주세요.',
|
child: _buildDirectInputContent(
|
||||||
style: TextStyle(
|
title: '반려동물의 품종을 직접 입력해주세요.',
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 16.sp,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 20.h),
|
|
||||||
TextField(
|
|
||||||
controller: manualInputController,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '예: 믹스, 시고르자브종 등',
|
hintText: '예: 믹스, 시고르자브종 등',
|
||||||
border: OutlineInputBorder(),
|
controller: manualInputController,
|
||||||
focusedBorder: OutlineInputBorder(
|
bottomInset: MediaQuery.of(context).viewInsets.bottom,
|
||||||
borderSide: BorderSide(color: AppColors.highlight),
|
onComplete: () {
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
height: 52.h,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (manualInputController.text.isNotEmpty) {
|
if (manualInputController.text.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_breedController.text = manualInputController.text;
|
_breedController.text = manualInputController.text;
|
||||||
@ -1344,25 +1225,8 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.highlight,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12.r),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'완료',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontSize: 16.sp,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: MediaQuery.of(context).viewInsets.bottom),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1595,25 +1459,7 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: CustomAppBar(title: isEditMode ? '반려동물 정보 수정' : '반려동물 등록'),
|
||||||
title: Text(
|
|
||||||
isEditMode ? '반려동물 정보 수정' : '반려동물 등록',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFF1f1f1f),
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 15.sp,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 16.w),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(20.w),
|
padding: EdgeInsets.all(20.w),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -1878,15 +1724,10 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
); // Reusing logic
|
); // Reusing logic
|
||||||
|
|
||||||
// Helper logic copy
|
// Helper logic copy
|
||||||
List<String> displayList = selected
|
_diseaseController.text = _generateDisplayString(
|
||||||
.where((e) => e != '기타')
|
selected,
|
||||||
.toList();
|
otherText,
|
||||||
if (selected.contains('기타') && otherText.isNotEmpty) {
|
);
|
||||||
displayList.add('기타($otherText)');
|
|
||||||
} else if (selected.contains('기타')) {
|
|
||||||
displayList.add('기타');
|
|
||||||
}
|
|
||||||
_diseaseController.text = displayList.join(', ');
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -1904,15 +1745,10 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedPastDiseases = selected;
|
_selectedPastDiseases = selected;
|
||||||
_otherPastDiseaseText = otherText;
|
_otherPastDiseaseText = otherText;
|
||||||
List<String> displayList = selected
|
_pastDiseaseController.text = _generateDisplayString(
|
||||||
.where((e) => e != '기타')
|
selected,
|
||||||
.toList();
|
otherText,
|
||||||
if (selected.contains('기타') && otherText.isNotEmpty) {
|
);
|
||||||
displayList.add('기타($otherText)');
|
|
||||||
} else if (selected.contains('기타')) {
|
|
||||||
displayList.add('기타');
|
|
||||||
}
|
|
||||||
_pastDiseaseController.text = displayList.join(', ');
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -1930,15 +1766,10 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedHealthConcerns = selected;
|
_selectedHealthConcerns = selected;
|
||||||
_otherHealthConcernText = otherText;
|
_otherHealthConcernText = otherText;
|
||||||
List<String> displayList = selected
|
_healthConcernController.text = _generateDisplayString(
|
||||||
.where((e) => e != '기타')
|
selected,
|
||||||
.toList();
|
otherText,
|
||||||
if (selected.contains('기타') && otherText.isNotEmpty) {
|
);
|
||||||
displayList.add('기타($otherText)');
|
|
||||||
} else if (selected.contains('기타')) {
|
|
||||||
displayList.add('기타');
|
|
||||||
}
|
|
||||||
_healthConcernController.text = displayList.join(', ');
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -2109,4 +1940,87 @@ class _PetFormScreenState extends State<PetFormScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper Widget: 직접 입력 UI (모달 내부 공통 사용)
|
||||||
|
Widget _buildDirectInputContent({
|
||||||
|
required String title,
|
||||||
|
required String hintText,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required VoidCallback onComplete,
|
||||||
|
required double bottomInset,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 16.sp,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
focusedBorder: const OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: AppColors.highlight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52.h,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onComplete,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.highlight,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'완료',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: bottomInset),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복잡한 질병 목록 처리 로직 (제출용)
|
||||||
|
List<String> _processSelectionForSubmit(
|
||||||
|
List<String> selected,
|
||||||
|
String otherText,
|
||||||
|
) {
|
||||||
|
List<String> finalList = List.from(selected);
|
||||||
|
if (finalList.contains('기타') && otherText.isNotEmpty) {
|
||||||
|
finalList.remove('기타');
|
||||||
|
finalList.add('기타($otherText)');
|
||||||
|
}
|
||||||
|
return finalList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 표시용 텍스트 생성 Helper
|
||||||
|
String _generateDisplayString(List<String> selected, String otherText) {
|
||||||
|
List<String> displayList = selected.where((e) => e != '기타').toList();
|
||||||
|
if (selected.contains('기타') && otherText.isNotEmpty) {
|
||||||
|
displayList.add('기타($otherText)');
|
||||||
|
} else if (selected.contains('기타')) {
|
||||||
|
displayList.add('기타');
|
||||||
|
}
|
||||||
|
return displayList.join(', ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../widgets/pet_registration/selection_modal.dart'; // Import SelectionMo
|
|||||||
import '../widgets/pet_registration/input_formatters.dart'; // Import InputFormatters
|
import '../widgets/pet_registration/input_formatters.dart'; // Import InputFormatters
|
||||||
import '../services/firestore_service.dart';
|
import '../services/firestore_service.dart';
|
||||||
import '../models/pet_model.dart';
|
import '../models/pet_model.dart';
|
||||||
|
import '../widgets/common/custom_app_bar.dart';
|
||||||
|
|
||||||
class PetRegistrationScreen extends StatefulWidget {
|
class PetRegistrationScreen extends StatefulWidget {
|
||||||
const PetRegistrationScreen({super.key});
|
const PetRegistrationScreen({super.key});
|
||||||
@ -1387,25 +1388,7 @@ class _PetRegistrationScreenState extends State<PetRegistrationScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: const CustomAppBar(title: '반려동물 등록'),
|
||||||
title: Text(
|
|
||||||
'반려동물 등록',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFF1f1f1f),
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 15.sp,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 16.w),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: EdgeInsets.all(20.w),
|
padding: EdgeInsets.all(20.w),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
1040
app/lib/screens/schedule_form_screen.dart
Normal file
@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; // Import screenuti
|
|||||||
import 'identity_verification_screen.dart';
|
import 'identity_verification_screen.dart';
|
||||||
import '../services/auth_service.dart'; // Import AuthService
|
import '../services/auth_service.dart'; // Import AuthService
|
||||||
import '../data/terms_data.dart';
|
import '../data/terms_data.dart';
|
||||||
|
import '../widgets/common/custom_app_bar.dart';
|
||||||
|
|
||||||
class TermsAgreementScreen extends StatefulWidget {
|
class TermsAgreementScreen extends StatefulWidget {
|
||||||
final bool isViewOnly;
|
final bool isViewOnly;
|
||||||
@ -139,24 +140,7 @@ class _TermsAgreementScreenState extends State<TermsAgreementScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: CustomAppBar(title: widget.isViewOnly ? '이용 약관' : '회원가입'),
|
||||||
backgroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back_ios, color: Colors.black, size: 20.w),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
widget.isViewOnly ? '이용 약관' : '회원가입',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 16.sp,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontFamily: 'SCDream',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -46,28 +46,22 @@ class ApiService {
|
|||||||
List<String>? healthConcerns,
|
List<String>? healthConcerns,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _createPetFormData(
|
||||||
'userId': userId,
|
userId: userId,
|
||||||
'name': name,
|
name: name,
|
||||||
'species': species,
|
species: species,
|
||||||
'breed': breed,
|
breed: breed,
|
||||||
'gender': gender, // "남아", "여아", "기타"
|
gender: gender,
|
||||||
'isNeutered': isNeutered,
|
isNeutered: isNeutered,
|
||||||
'birthDate': birthDate?.toIso8601String(),
|
birthDate: birthDate,
|
||||||
'isDateUnknown': isDateUnknown,
|
isDateUnknown: isDateUnknown,
|
||||||
'weight': weight,
|
weight: weight,
|
||||||
'registrationNumber': registrationNumber,
|
registrationNumber: registrationNumber,
|
||||||
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
|
profileImage: profileImage,
|
||||||
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
|
diseases: diseases,
|
||||||
'healthConcerns': healthConcerns != null
|
pastDiseases: pastDiseases,
|
||||||
? jsonEncode(healthConcerns)
|
healthConcerns: healthConcerns,
|
||||||
: '[]',
|
);
|
||||||
if (profileImage != null)
|
|
||||||
'profileImage': await MultipartFile.fromFile(
|
|
||||||
profileImage.path,
|
|
||||||
filename: profileImage.path.split('/').last,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
final response = await _dio.post('/pets', data: formData);
|
final response = await _dio.post('/pets', data: formData);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -77,7 +71,6 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Pet
|
|
||||||
Future<Map<String, dynamic>> updatePet({
|
Future<Map<String, dynamic>> updatePet({
|
||||||
required int petId,
|
required int petId,
|
||||||
required String name,
|
required String name,
|
||||||
@ -95,27 +88,21 @@ class ApiService {
|
|||||||
List<String>? healthConcerns,
|
List<String>? healthConcerns,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _createPetFormData(
|
||||||
'name': name,
|
name: name,
|
||||||
'species': species,
|
species: species,
|
||||||
'breed': breed,
|
breed: breed,
|
||||||
'gender': gender,
|
gender: gender,
|
||||||
'isNeutered': isNeutered,
|
isNeutered: isNeutered,
|
||||||
'birthDate': birthDate?.toIso8601String(),
|
birthDate: birthDate,
|
||||||
'isDateUnknown': isDateUnknown,
|
isDateUnknown: isDateUnknown,
|
||||||
'weight': weight,
|
weight: weight,
|
||||||
'registrationNumber': registrationNumber,
|
registrationNumber: registrationNumber,
|
||||||
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
|
profileImage: profileImage,
|
||||||
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
|
diseases: diseases,
|
||||||
'healthConcerns': healthConcerns != null
|
pastDiseases: pastDiseases,
|
||||||
? jsonEncode(healthConcerns)
|
healthConcerns: healthConcerns,
|
||||||
: '[]',
|
);
|
||||||
if (profileImage != null)
|
|
||||||
'profileImage': await MultipartFile.fromFile(
|
|
||||||
profileImage.path,
|
|
||||||
filename: profileImage.path.split('/').last,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
final response = await _dio.put('/pets/$petId', data: formData);
|
final response = await _dio.put('/pets/$petId', data: formData);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -125,6 +112,51 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: Create FormData
|
||||||
|
Future<FormData> _createPetFormData({
|
||||||
|
required String name,
|
||||||
|
required String species,
|
||||||
|
required String breed,
|
||||||
|
required String gender,
|
||||||
|
required bool isNeutered,
|
||||||
|
DateTime? birthDate,
|
||||||
|
required bool isDateUnknown,
|
||||||
|
double? weight,
|
||||||
|
String? registrationNumber,
|
||||||
|
File? profileImage,
|
||||||
|
List<String>? diseases,
|
||||||
|
List<String>? pastDiseases,
|
||||||
|
List<String>? healthConcerns,
|
||||||
|
int? userId, // Optional (only for register)
|
||||||
|
}) async {
|
||||||
|
final map = {
|
||||||
|
if (userId != null) 'userId': userId,
|
||||||
|
'name': name,
|
||||||
|
'species': species,
|
||||||
|
'breed': breed,
|
||||||
|
'gender': gender,
|
||||||
|
'isNeutered': isNeutered,
|
||||||
|
'birthDate': birthDate?.toIso8601String(),
|
||||||
|
'isDateUnknown': isDateUnknown,
|
||||||
|
'weight': weight,
|
||||||
|
'registrationNumber': registrationNumber,
|
||||||
|
'diseases': diseases != null ? jsonEncode(diseases) : '[]',
|
||||||
|
'pastDiseases': pastDiseases != null ? jsonEncode(pastDiseases) : '[]',
|
||||||
|
'healthConcerns': healthConcerns != null
|
||||||
|
? jsonEncode(healthConcerns)
|
||||||
|
: '[]',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileImage != null) {
|
||||||
|
map['profileImage'] = await MultipartFile.fromFile(
|
||||||
|
profileImage.path,
|
||||||
|
filename: profileImage.path.split('/').last,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormData.fromMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
// Get Pets
|
// Get Pets
|
||||||
Future<List<dynamic>> getPets(int userId) async {
|
Future<List<dynamic>> getPets(int userId) async {
|
||||||
try {
|
try {
|
||||||
@ -138,4 +170,59 @@ class ApiService {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Schedule APIs ---
|
||||||
|
|
||||||
|
// Get Monthly Schedules
|
||||||
|
Future<List<dynamic>> getSchedules({
|
||||||
|
required String petId,
|
||||||
|
required int year,
|
||||||
|
required int month,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/schedules',
|
||||||
|
queryParameters: {'petId': petId, 'year': year, 'month': month},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error fetching schedules: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Schedule
|
||||||
|
Future<Map<String, dynamic>> createSchedule(Map<String, dynamic> data) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post('/schedules', data: data);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error creating schedule: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Schedule
|
||||||
|
Future<Map<String, dynamic>> updateSchedule(
|
||||||
|
String id,
|
||||||
|
Map<String, dynamic> data,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.put('/schedules/$id', data: data);
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error updating schedule: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Schedule
|
||||||
|
Future<void> deleteSchedule(String id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/schedules/$id');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error deleting schedule: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:firebase_storage/firebase_storage.dart';
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/pet_model.dart';
|
import '../models/pet_model.dart';
|
||||||
|
import '../models/schedule_model.dart';
|
||||||
import '../utils/log_manager.dart';
|
import '../utils/log_manager.dart';
|
||||||
|
|
||||||
class FirestoreService {
|
class FirestoreService {
|
||||||
@ -147,4 +148,79 @@ class FirestoreService {
|
|||||||
String generatePetId() {
|
String generatePetId() {
|
||||||
return const Uuid().v4();
|
return const Uuid().v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 일정 추가
|
||||||
|
Future<void> addSchedule(Schedule schedule) async {
|
||||||
|
try {
|
||||||
|
await _db.collection('schedules').doc(schedule.id).set(schedule.toMap());
|
||||||
|
} catch (e) {
|
||||||
|
LogManager().addLog('[Firestore] Error adding schedule: $e');
|
||||||
|
throw Exception('일정 추가 실패: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일정 조회 (특정 반려동물, 특정 날짜)
|
||||||
|
// 날짜는 년,월,일 만 비교를 위해 range query 사용 (startOfDay ~ endOfDay)
|
||||||
|
Stream<List<Schedule>> getSchedules(String petId, DateTime date) {
|
||||||
|
DateTime startOfDay = DateTime(date.year, date.month, date.day);
|
||||||
|
DateTime endOfDay = DateTime(date.year, date.month, date.day, 23, 59, 59);
|
||||||
|
|
||||||
|
return _db
|
||||||
|
.collection('schedules')
|
||||||
|
.where('petId', isEqualTo: petId)
|
||||||
|
.where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfDay))
|
||||||
|
.where('date', isLessThanOrEqualTo: Timestamp.fromDate(endOfDay))
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
return snapshot.docs
|
||||||
|
.map((doc) => Schedule.fromMap(doc.data()))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 월간 일정 조회 (도장 모드용) - 해당 월의 모든 일정 가져오기
|
||||||
|
Stream<List<Schedule>> getMonthlySchedules(String petId, DateTime month) {
|
||||||
|
DateTime startOfMonth = DateTime(month.year, month.month, 1);
|
||||||
|
DateTime endOfMonth = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
||||||
|
|
||||||
|
return _db
|
||||||
|
.collection('schedules')
|
||||||
|
.where('petId', isEqualTo: petId)
|
||||||
|
.where('date', isGreaterThanOrEqualTo: Timestamp.fromDate(startOfMonth))
|
||||||
|
.where('date', isLessThanOrEqualTo: Timestamp.fromDate(endOfMonth))
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
return snapshot.docs
|
||||||
|
.map((doc) => Schedule.fromMap(doc.data()))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일정 수정
|
||||||
|
Future<void> updateSchedule(Schedule schedule) async {
|
||||||
|
try {
|
||||||
|
await _db
|
||||||
|
.collection('schedules')
|
||||||
|
.doc(schedule.id)
|
||||||
|
.update(schedule.toMap());
|
||||||
|
} catch (e) {
|
||||||
|
LogManager().addLog('[Firestore] Error updating schedule: $e');
|
||||||
|
throw Exception('일정 수정 실패: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일정 삭제
|
||||||
|
Future<void> deleteSchedule(String scheduleId) async {
|
||||||
|
try {
|
||||||
|
await _db.collection('schedules').doc(scheduleId).delete();
|
||||||
|
} catch (e) {
|
||||||
|
LogManager().addLog('[Firestore] Error deleting schedule: $e');
|
||||||
|
throw Exception('일정 삭제 실패: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID 생성
|
||||||
|
String generateScheduleId() {
|
||||||
|
return const Uuid().v4();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
app/lib/widgets/common/custom_app_bar.dart
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget? leading;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final VoidCallback? onLeadingPressed;
|
||||||
|
final bool showBottomBorder;
|
||||||
|
|
||||||
|
const CustomAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.leading,
|
||||||
|
this.actions,
|
||||||
|
this.onLeadingPressed,
|
||||||
|
this.showBottomBorder = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leading:
|
||||||
|
leading ??
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios, color: Colors.black),
|
||||||
|
onPressed: onLeadingPressed ?? () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
bottom: showBottomBorder
|
||||||
|
? PreferredSize(
|
||||||
|
preferredSize: Size.fromHeight(1.h),
|
||||||
|
child: Divider(
|
||||||
|
color: const Color(0xFFEEEEEE),
|
||||||
|
thickness: 1.h,
|
||||||
|
height: 1.h,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize =>
|
||||||
|
Size.fromHeight(kToolbarHeight + (showBottomBorder ? 1.h : 0));
|
||||||
|
}
|
||||||
366
app/lib/widgets/common/custom_time_picker.dart
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
class CustomTimePicker extends StatefulWidget {
|
||||||
|
final TimeOfDay initialTime;
|
||||||
|
final ValueChanged<TimeOfDay> onTimeChanged;
|
||||||
|
|
||||||
|
const CustomTimePicker({
|
||||||
|
super.key,
|
||||||
|
required this.initialTime,
|
||||||
|
required this.onTimeChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomTimePicker> createState() => _CustomTimePickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomTimePickerState extends State<CustomTimePicker> {
|
||||||
|
late FixedExtentScrollController _amPmController;
|
||||||
|
late FixedExtentScrollController _hourController;
|
||||||
|
late FixedExtentScrollController _minuteController;
|
||||||
|
|
||||||
|
// 시간 직접 입력을 위한 컨트롤러
|
||||||
|
final TextEditingController _hourInputController = TextEditingController();
|
||||||
|
final TextEditingController _minuteInputController = TextEditingController();
|
||||||
|
|
||||||
|
// 입력 모드 상태 관리
|
||||||
|
bool _isEditingHour = false;
|
||||||
|
bool _isEditingMinute = false;
|
||||||
|
final FocusNode _hourFocusNode = FocusNode();
|
||||||
|
final FocusNode _minuteFocusNode = FocusNode();
|
||||||
|
|
||||||
|
// 내부 상태 (부모와 동기화)
|
||||||
|
late int _selectedAmPm; // 0: AM, 1: PM
|
||||||
|
late int _selectedHour; // 1~12
|
||||||
|
late int _selectedMinute; // 0~59
|
||||||
|
|
||||||
|
// 외부 업데이트 중인지 확인하는 플래그
|
||||||
|
bool _isSyncing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(CustomTimePicker oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.initialTime != widget.initialTime) {
|
||||||
|
_isSyncing = true; // 동기화 시작
|
||||||
|
try {
|
||||||
|
_updateInternalStateFromWidget();
|
||||||
|
|
||||||
|
// jumpToItem은 onSelectedItemChanged를 트리거할 수도 있으므로(구현에 따라 다름)
|
||||||
|
// 플래그로 콜백 호출을 막습니다.
|
||||||
|
if (_amPmController.hasClients) {
|
||||||
|
_amPmController.jumpToItem(_selectedAmPm);
|
||||||
|
}
|
||||||
|
if (_hourController.hasClients) {
|
||||||
|
_hourController.jumpToItem(_selectedHour - 1);
|
||||||
|
}
|
||||||
|
if (_minuteController.hasClients) {
|
||||||
|
_minuteController.jumpToItem(_selectedMinute);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isSyncing = false; // 동기화 종료
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeState() {
|
||||||
|
_updateInternalStateFromWidget();
|
||||||
|
|
||||||
|
_amPmController = FixedExtentScrollController(initialItem: _selectedAmPm);
|
||||||
|
_hourController = FixedExtentScrollController(
|
||||||
|
initialItem: _selectedHour - 1,
|
||||||
|
);
|
||||||
|
_minuteController = FixedExtentScrollController(
|
||||||
|
initialItem: _selectedMinute,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateInternalStateFromWidget() {
|
||||||
|
final t = widget.initialTime;
|
||||||
|
_selectedAmPm = t.period == DayPeriod.pm ? 1 : 0;
|
||||||
|
_selectedHour = t.hourOfPeriod == 0 ? 12 : t.hourOfPeriod;
|
||||||
|
_selectedMinute = t.minute;
|
||||||
|
|
||||||
|
if (!_isEditingHour) {
|
||||||
|
_hourInputController.text = _selectedHour.toString();
|
||||||
|
}
|
||||||
|
if (!_isEditingMinute) {
|
||||||
|
_minuteInputController.text = _selectedMinute.toString().padLeft(2, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyTimeChanged() {
|
||||||
|
if (_isSyncing) return; // 외부 동기화 중이면 부모에게 알리지 않음
|
||||||
|
|
||||||
|
// 내부 상태 -> TimeOfDay 변환 후 콜백 호출
|
||||||
|
int hour24 = _selectedHour;
|
||||||
|
if (_selectedAmPm == 1 && _selectedHour < 12) {
|
||||||
|
hour24 += 12;
|
||||||
|
} else if (_selectedAmPm == 0 && _selectedHour == 12) {
|
||||||
|
hour24 = 0;
|
||||||
|
}
|
||||||
|
widget.onTimeChanged(TimeOfDay(hour: hour24, minute: _selectedMinute));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_amPmController.dispose();
|
||||||
|
_hourController.dispose();
|
||||||
|
_minuteController.dispose();
|
||||||
|
_hourInputController.dispose();
|
||||||
|
_minuteInputController.dispose();
|
||||||
|
_hourFocusNode.dispose();
|
||||||
|
_minuteFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 150.h,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 오전/오후
|
||||||
|
_buildWheelPicker(
|
||||||
|
controller: _amPmController,
|
||||||
|
items: ['오전', '오후'],
|
||||||
|
onChanged: (index) {
|
||||||
|
if (_isSyncing) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedAmPm = index;
|
||||||
|
});
|
||||||
|
_notifyTimeChanged();
|
||||||
|
},
|
||||||
|
width: 60.w,
|
||||||
|
height: 150.h,
|
||||||
|
selectedIndex: _selectedAmPm,
|
||||||
|
),
|
||||||
|
|
||||||
|
// 시
|
||||||
|
_buildWheelPicker(
|
||||||
|
controller: _hourController,
|
||||||
|
items: List.generate(12, (index) => (index + 1).toString()),
|
||||||
|
onChanged: (index) {
|
||||||
|
if (_isSyncing) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedHour = index + 1;
|
||||||
|
if (!_isEditingHour) {
|
||||||
|
_hourInputController.text = _selectedHour.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyTimeChanged();
|
||||||
|
},
|
||||||
|
width: 60.w,
|
||||||
|
height: 150.h,
|
||||||
|
isNumber: true,
|
||||||
|
inputController: _hourInputController,
|
||||||
|
isEditing: _isEditingHour,
|
||||||
|
focusNode: _hourFocusNode,
|
||||||
|
selectedIndex: _selectedHour - 1,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isEditingHour = true;
|
||||||
|
_isEditingMinute = false;
|
||||||
|
});
|
||||||
|
_hourFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
onEditingComplete: () {
|
||||||
|
setState(() => _isEditingHour = false);
|
||||||
|
},
|
||||||
|
onInputChanged: (value) {
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
int? hour = int.tryParse(value);
|
||||||
|
if (hour != null && hour >= 1 && hour <= 12) {
|
||||||
|
setState(() => _selectedHour = hour);
|
||||||
|
if (_hourController.hasClients) {
|
||||||
|
_hourController.jumpToItem(hour - 1);
|
||||||
|
}
|
||||||
|
_notifyTimeChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-focus logic
|
||||||
|
if (value.length == 2) {
|
||||||
|
setState(() {
|
||||||
|
_isEditingHour = false;
|
||||||
|
_isEditingMinute = true;
|
||||||
|
});
|
||||||
|
_minuteFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
height: 150.h,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
':',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 24.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 분
|
||||||
|
_buildWheelPicker(
|
||||||
|
controller: _minuteController,
|
||||||
|
items: List.generate(
|
||||||
|
60,
|
||||||
|
(index) => index.toString().padLeft(2, '0'),
|
||||||
|
),
|
||||||
|
onChanged: (index) {
|
||||||
|
if (_isSyncing) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedMinute = index;
|
||||||
|
if (!_isEditingMinute) {
|
||||||
|
_minuteInputController.text = _selectedMinute
|
||||||
|
.toString()
|
||||||
|
.padLeft(2, '0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_notifyTimeChanged();
|
||||||
|
},
|
||||||
|
width: 60.w,
|
||||||
|
height: 150.h,
|
||||||
|
isNumber: true,
|
||||||
|
inputController: _minuteInputController,
|
||||||
|
isEditing: _isEditingMinute,
|
||||||
|
focusNode: _minuteFocusNode,
|
||||||
|
selectedIndex: _selectedMinute,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isEditingMinute = true;
|
||||||
|
_isEditingHour = false;
|
||||||
|
});
|
||||||
|
_minuteFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
onEditingComplete: () {
|
||||||
|
setState(() => _isEditingMinute = false);
|
||||||
|
},
|
||||||
|
onInputChanged: (value) {
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
int? minute = int.tryParse(value);
|
||||||
|
if (minute != null && minute >= 0 && minute <= 59) {
|
||||||
|
setState(() => _selectedMinute = minute);
|
||||||
|
if (_minuteController.hasClients) {
|
||||||
|
_minuteController.jumpToItem(minute);
|
||||||
|
}
|
||||||
|
_notifyTimeChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWheelPicker({
|
||||||
|
required FixedExtentScrollController controller,
|
||||||
|
required List<String> items,
|
||||||
|
required Function(int) onChanged,
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
required int selectedIndex,
|
||||||
|
bool isNumber = false,
|
||||||
|
TextEditingController? inputController,
|
||||||
|
Function(String)? onInputChanged,
|
||||||
|
bool isEditing = false,
|
||||||
|
FocusNode? focusNode,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
VoidCallback? onEditingComplete,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CupertinoPicker(
|
||||||
|
scrollController: controller,
|
||||||
|
itemExtent: 50.h,
|
||||||
|
onSelectedItemChanged: onChanged,
|
||||||
|
selectionOverlay: null,
|
||||||
|
diameterRatio: 99,
|
||||||
|
squeeze: 1.1,
|
||||||
|
children: items.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final item = entry.value;
|
||||||
|
final isSelected = index == selectedIndex;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (isSelected) {
|
||||||
|
if (isNumber) {
|
||||||
|
onTap?.call();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.animateToItem(
|
||||||
|
index,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
item,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 20.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
if (isEditing && inputController != null && focusNode != null)
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: width,
|
||||||
|
height: 50.h,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: const BoxDecoration(color: Colors.white),
|
||||||
|
child: TextField(
|
||||||
|
controller: inputController,
|
||||||
|
focusNode: focusNode,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLength: 2,
|
||||||
|
autofocus: true,
|
||||||
|
onEditingComplete: onEditingComplete,
|
||||||
|
onTapOutside: (_) => onEditingComplete?.call(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
counterText: "",
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 20.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
onChanged: onInputChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
app/lib/widgets/common/pet_selection_header.dart
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import '../../models/pet_model.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class PetSelectionHeader extends StatelessWidget {
|
||||||
|
final List<Pet> pets;
|
||||||
|
final Pet? selectedPet;
|
||||||
|
final ValueChanged<Pet> onPetSelected;
|
||||||
|
final VoidCallback onAddPetPressed;
|
||||||
|
|
||||||
|
const PetSelectionHeader({
|
||||||
|
super.key,
|
||||||
|
required this.pets,
|
||||||
|
required this.selectedPet,
|
||||||
|
required this.onPetSelected,
|
||||||
|
required this.onAddPetPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// If no pet selected (and list is empty), show Add Pet UI or minimal hint
|
||||||
|
if (pets.isEmpty) {
|
||||||
|
// Returning null or minimal UI might be better, but user asked for "Consistent Header"
|
||||||
|
// The HomeScreen shows a "Register Pet + " button if empty.
|
||||||
|
// Let's implement that logic here for consistency if pets are empty.
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
|
onTap: onAddPetPressed,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 12.w),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/img/profile.png',
|
||||||
|
width: 40.w,
|
||||||
|
height: 40.h,
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Text(
|
||||||
|
'반려동물 등록 +',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 15.sp,
|
||||||
|
letterSpacing: 0.45.sp,
|
||||||
|
color: const Color(0xFF1f1f1f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default display pet
|
||||||
|
final displayPet = selectedPet ?? pets.first;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
|
onTap: () {
|
||||||
|
final RenderBox button = context.findRenderObject() as RenderBox;
|
||||||
|
final RenderBox overlay =
|
||||||
|
Navigator.of(context).overlay!.context.findRenderObject()
|
||||||
|
as RenderBox;
|
||||||
|
final RelativeRect position = RelativeRect.fromRect(
|
||||||
|
Rect.fromPoints(
|
||||||
|
button.localToGlobal(Offset(0, 50.h), ancestor: overlay),
|
||||||
|
button.localToGlobal(
|
||||||
|
button.size.bottomRight(Offset.zero),
|
||||||
|
ancestor: overlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Offset.zero & overlay.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
showMenu<dynamic>(
|
||||||
|
context: context,
|
||||||
|
position: position,
|
||||||
|
elevation: 3,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
color: Colors.white,
|
||||||
|
surfaceTintColor: Colors.white,
|
||||||
|
items: [
|
||||||
|
...pets.map(
|
||||||
|
(pet) => PopupMenuItem<Pet>(
|
||||||
|
value: pet,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32.w,
|
||||||
|
height: 32.w,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: pet.profileImageUrl != null
|
||||||
|
? Image.network(
|
||||||
|
pet.profileImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/profile_icon.svg',
|
||||||
|
width: 16.w,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.grey[400]!,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/profile_icon.svg',
|
||||||
|
width: 16.w,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.grey[400]!,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
Text(
|
||||||
|
pet.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: pet.id == displayPet.id
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: pet.id == displayPet.id
|
||||||
|
? AppColors.highlight
|
||||||
|
: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (pet.id == displayPet.id) ...[
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: AppColors.highlight,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
value: 'add_pet',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(4.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.add, size: 16.w, color: Colors.black54),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
Text(
|
||||||
|
'반려동물 추가하기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).then((value) {
|
||||||
|
if (value != null) {
|
||||||
|
if (value is Pet) {
|
||||||
|
onPetSelected(value);
|
||||||
|
} else if (value == 'add_pet') {
|
||||||
|
onAddPetPressed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 12.w),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40.w,
|
||||||
|
height: 40.w,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.grey[200],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: displayPet.profileImageUrl != null
|
||||||
|
? Image.network(
|
||||||
|
displayPet.profileImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/profile_icon.svg',
|
||||||
|
width: 20.w,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.grey[400]!,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/profile_icon.svg',
|
||||||
|
width: 20.w,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.grey[400]!,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
Text(
|
||||||
|
displayPet.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18.sp,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 4.w),
|
||||||
|
Icon(Icons.keyboard_arrow_down, size: 24.w, color: Colors.black),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
252
app/lib/widgets/daily_care/schedule_form_sheet.dart
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/schedule_model.dart';
|
||||||
|
import '../../providers/pet_provider.dart';
|
||||||
|
import '../../services/firestore_service.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class ScheduleFormSheet extends StatefulWidget {
|
||||||
|
final DateTime selectedDate;
|
||||||
|
|
||||||
|
const ScheduleFormSheet({super.key, required this.selectedDate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScheduleFormSheet> createState() => _ScheduleFormSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleFormSheetState extends State<ScheduleFormSheet> {
|
||||||
|
final TextEditingController _titleController = TextEditingController();
|
||||||
|
final TextEditingController _noteController = TextEditingController();
|
||||||
|
String _selectedType = 'general'; // 'general' or 'important'
|
||||||
|
bool _isSubstituting = false; // "저장 중" 상태 표시
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_noteController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSchedule() async {
|
||||||
|
final title = _titleController.text.trim();
|
||||||
|
if (title.isEmpty) return;
|
||||||
|
|
||||||
|
final petProvider = context.read<PetProvider>();
|
||||||
|
final selectedPet = petProvider.selectedPet;
|
||||||
|
|
||||||
|
if (selectedPet == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('반려동물을 먼저 선택해주세요.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubstituting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final firestoreService = FirestoreService();
|
||||||
|
final newSchedule = Schedule(
|
||||||
|
id: firestoreService.generateScheduleId(),
|
||||||
|
petId: selectedPet.id,
|
||||||
|
date: widget.selectedDate,
|
||||||
|
type: _selectedType,
|
||||||
|
isCompleted: false,
|
||||||
|
title: title,
|
||||||
|
note: _noteController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _noteController.text.trim(),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await firestoreService.addSchedule(newSchedule);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context, true); // 성공 시 true 반환
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving schedule: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('일정 저장 실패: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSubstituting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20.w,
|
||||||
|
right: 20.w,
|
||||||
|
top: 20.h,
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20.h,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24.r)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'일정 추가',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Divider(color: const Color(0xFFEEEEEE), thickness: 1.h),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
|
||||||
|
// 제목 입력
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '일정 제목',
|
||||||
|
floatingLabelStyle: const TextStyle(color: AppColors.highlight),
|
||||||
|
hintText: '예: 산책하기, 병원 방문',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
borderSide: const BorderSide(color: AppColors.highlight),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[50],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
|
||||||
|
// 중요도 선택
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildTypeButton(
|
||||||
|
'일반',
|
||||||
|
'general',
|
||||||
|
AppColors.subHighlight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: _buildTypeButton('중요', 'important', AppColors.highlight),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
|
||||||
|
// 메모 입력
|
||||||
|
TextField(
|
||||||
|
controller: _noteController,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '메모 (선택)',
|
||||||
|
floatingLabelStyle: const TextStyle(color: AppColors.highlight),
|
||||||
|
hintText: '추가적인 내용을 입력하세요',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
borderSide: const BorderSide(color: AppColors.highlight),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey[50],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 24.h),
|
||||||
|
|
||||||
|
// 저장 버튼
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isSubstituting ? null : _saveSchedule,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.highlight,
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: _isSubstituting
|
||||||
|
? SizedBox(
|
||||||
|
width: 24.w,
|
||||||
|
height: 24.w,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'저장하기',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTypeButton(String label, String value, Color color) {
|
||||||
|
final isSelected = _selectedType == value;
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? color.withOpacity(0.1) : Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? color : Colors.grey[300]!,
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12.w,
|
||||||
|
height: 12.w,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SCDream',
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isSelected ? color : Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -144,21 +144,33 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// 왼쪽: 프로필 이미지
|
||||||
// 왼쪽: 프로필 이미지
|
// 왼쪽: 프로필 이미지
|
||||||
Container(
|
Container(
|
||||||
width: 120.w,
|
width: 120.w,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
color: Colors.grey[200],
|
color: Colors.grey[200],
|
||||||
image: pet.profileImageUrl != null
|
|
||||||
? DecorationImage(
|
|
||||||
image: NetworkImage(pet.profileImageUrl!),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
child: pet.profileImageUrl == null
|
clipBehavior: Clip.hardEdge,
|
||||||
? Center(
|
child: pet.profileImageUrl != null
|
||||||
|
? Image.network(
|
||||||
|
pet.profileImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/profile_icon.svg',
|
||||||
|
width: 40.w,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.grey[400]!,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
'assets/icons/profile_icon.svg',
|
'assets/icons/profile_icon.svg',
|
||||||
width: 40.w,
|
width: 40.w,
|
||||||
@ -167,8 +179,7 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
BlendMode.srcIn,
|
BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 10.w),
|
SizedBox(width: 10.w),
|
||||||
// 오른쪽: 정보 영역
|
// 오른쪽: 정보 영역
|
||||||
@ -196,12 +207,12 @@ class PetProfileCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (pet.gender == '남아' || pet.gender == '여아') ...[
|
if (pet.gender == '남아' || pet.gender == '여아') ...[
|
||||||
SizedBox(width: 6.w),
|
SizedBox(width: 6.w),
|
||||||
Icon(
|
SvgPicture.asset(
|
||||||
pet.gender == '남아' ? Icons.male : Icons.female,
|
pet.gender == '남아'
|
||||||
color: pet.gender == '남아'
|
? 'assets/icons/male_icon.svg'
|
||||||
? Colors.blue
|
: 'assets/icons/female_icon.svg',
|
||||||
: Colors.pinkAccent,
|
width: 20.w,
|
||||||
size: 20.sp,
|
height: 20.w,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -616,6 +616,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.4"
|
version: "0.17.4"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -712,6 +720,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: provider
|
||||||
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -47,6 +47,7 @@ dependencies:
|
|||||||
firebase_storage: ^12.0.0
|
firebase_storage: ^12.0.0
|
||||||
uuid: ^4.0.0
|
uuid: ^4.0.0
|
||||||
table_calendar: ^3.0.9
|
table_calendar: ^3.0.9
|
||||||
|
provider: ^6.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const { connectDB, sequelize } = require('./config/db');
|
|||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const commonRoutes = require('./routes/common');
|
const commonRoutes = require('./routes/common');
|
||||||
const petRoutes = require('./routes/pets');
|
const petRoutes = require('./routes/pets');
|
||||||
|
const scheduleRoutes = require('./routes/schedules');
|
||||||
const seedData = require('./scripts/seedData');
|
const seedData = require('./scripts/seedData');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -22,6 +23,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
|||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
app.use('/common', commonRoutes);
|
app.use('/common', commonRoutes);
|
||||||
app.use('/pets', petRoutes);
|
app.use('/pets', petRoutes);
|
||||||
|
app.use('/schedules', scheduleRoutes);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.send('Hello from Express Backend!');
|
res.send('Hello from Express Backend!');
|
||||||
|
|||||||
59
backend/models/Schedule.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/db');
|
||||||
|
|
||||||
|
const Schedule = sequelize.define('Schedule', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
autoIncrement: true,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
petId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Reference to Pet table',
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Schedule start date/time',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'general', // 'general', 'important'
|
||||||
|
},
|
||||||
|
isCompleted: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
repeatInterval: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Repeat interval (e.g. 1, 2)',
|
||||||
|
},
|
||||||
|
repeatUnit: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Repeat unit (day, week, month, year)',
|
||||||
|
},
|
||||||
|
isAlarmOn: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
alarmTime: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
timestamps: true, // adds createdAt, updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Schedule;
|
||||||
@ -6,6 +6,7 @@ const PetSpecies = require('./PetSpecies');
|
|||||||
const PetBreed = require('./PetBreed');
|
const PetBreed = require('./PetBreed');
|
||||||
const PetDisease = require('./PetDisease');
|
const PetDisease = require('./PetDisease');
|
||||||
const Pet = require('./Pet');
|
const Pet = require('./Pet');
|
||||||
|
const Schedule = require('./Schedule');
|
||||||
|
|
||||||
// Associations
|
// Associations
|
||||||
|
|
||||||
@ -20,6 +21,10 @@ PetBreed.belongsTo(PetSpecies, { foreignKey: 'speciesId' });
|
|||||||
User.hasMany(Pet, { foreignKey: 'userId' });
|
User.hasMany(Pet, { foreignKey: 'userId' });
|
||||||
Pet.belongsTo(User, { foreignKey: 'userId' });
|
Pet.belongsTo(User, { foreignKey: 'userId' });
|
||||||
|
|
||||||
|
// Pet <-> Schedule
|
||||||
|
Pet.hasMany(Schedule, { foreignKey: 'petId' });
|
||||||
|
Schedule.belongsTo(Pet, { foreignKey: 'petId' });
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sequelize,
|
sequelize,
|
||||||
@ -29,4 +34,5 @@ module.exports = {
|
|||||||
PetBreed,
|
PetBreed,
|
||||||
PetDisease,
|
PetDisease,
|
||||||
Pet,
|
Pet,
|
||||||
|
Schedule,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -34,6 +34,16 @@ router.post('/', upload.single('profileImage'), async (req, res) => {
|
|||||||
|
|
||||||
const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null;
|
const profileImagePath = req.file ? `/uploads/pets/${req.file.filename}` : null;
|
||||||
|
|
||||||
|
// Check pet count limit (Max 20)
|
||||||
|
const currentPetCount = await Pet.count({ where: { userId } });
|
||||||
|
if (currentPetCount >= 20) {
|
||||||
|
// Clean up uploaded file if limit exceeded
|
||||||
|
if (req.file) {
|
||||||
|
fs.unlinkSync(path.join(__dirname, '../uploads/pets/', req.file.filename));
|
||||||
|
}
|
||||||
|
return res.status(400).json({ message: '최대 20마리까지만 등록할 수 있습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
const newPet = await Pet.create({
|
const newPet = await Pet.create({
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
108
backend/routes/schedules.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Schedule } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// GET /schedules
|
||||||
|
// Query: petId (required), year, month (optional, for monthly view)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { petId, year, month } = req.query;
|
||||||
|
|
||||||
|
if (!petId) {
|
||||||
|
return res.status(400).json({ message: 'Missing petId query parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = { petId };
|
||||||
|
|
||||||
|
if (year && month) {
|
||||||
|
const startDate = new Date(year, month - 1, 1);
|
||||||
|
const endDate = new Date(year, month, 0, 23, 59, 59); // Last day of month
|
||||||
|
|
||||||
|
whereClause.date = {
|
||||||
|
[Op.between]: [startDate, endDate]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = await Schedule.findAll({
|
||||||
|
where: whereClause,
|
||||||
|
order: [['date', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(schedules);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching schedules:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error', error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /schedules
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
petId, date, type, isCompleted, title, note,
|
||||||
|
repeatInterval, repeatUnit, isAlarmOn, alarmTime
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const newSchedule = await Schedule.create({
|
||||||
|
petId,
|
||||||
|
date: new Date(date),
|
||||||
|
type,
|
||||||
|
isCompleted: isCompleted || false,
|
||||||
|
title,
|
||||||
|
note,
|
||||||
|
repeatInterval,
|
||||||
|
repeatUnit,
|
||||||
|
isAlarmOn,
|
||||||
|
alarmTime: alarmTime ? new Date(alarmTime) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(newSchedule);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating schedule:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error', error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /schedules/:id
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
const schedule = await Schedule.findByPk(id);
|
||||||
|
if (!schedule) {
|
||||||
|
return res.status(404).json({ message: 'Schedule not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety checks / parsing
|
||||||
|
if (updateData.date) updateData.date = new Date(updateData.date);
|
||||||
|
if (updateData.alarmTime) updateData.alarmTime = new Date(updateData.alarmTime);
|
||||||
|
|
||||||
|
await schedule.update(updateData);
|
||||||
|
|
||||||
|
res.json(schedule);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating schedule:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error', error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /schedules/:id
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const schedule = await Schedule.findByPk(id);
|
||||||
|
if (!schedule) {
|
||||||
|
return res.status(404).json({ message: 'Schedule not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await schedule.destroy();
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting schedule:', error);
|
||||||
|
res.status(500).json({ message: 'Server Error', error: error.toString() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
BIN
graphic_images.jpg
Normal file
|
After Width: | Height: | Size: 221 KiB |