feat(flutter): 반려동물 등록·수정 화면과 기기 저장소 구현

시안 screen-register · pet-species 를 옮겼다.
- 사진(앨범 선택 → 앱 폴더로 복사), 이름, 종·품종 검색 시트(12 대분류), 성별·중성화,
  생년월일 휠, 몸무게(kg/g), 건강 정보, 알레르기(직접 추가 항목은 다음 등록에도 유지), 메모
- 필수값이 빠지면 칸마다 오류를 표시하고 첫 빠진 칸으로 스크롤
- 수정 모드에서 삭제(확인창) 지원
- 저장은 PetRepository 계약 뒤의 LocalPetRepository(SharedPreferences JSON) —
  백엔드 /api/pets 가 생기면 Dio 구현으로 바꾼다
- image_picker · path_provider 추가, iOS 사진 접근 문구(NSPhotoLibraryUsageDescription)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:35:39 +09:00
parent 544281ed8e
commit 50d1aeea4b
42 changed files with 2023 additions and 4 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="161 64 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M161 88.2316C161 83.5539 161 81.2206 161.91 79.433C162.714 77.8651 163.99 76.5892 165.558 75.7855C167.34 74.875 169.679 74.875 174.357 74.875H190.737C191.532 74.875 192.177 75.5194 192.177 76.3144C192.177 77.1127 191.527 77.7585 190.729 77.7537L174.44 77.6566H174.44C172.054 77.6566 170.431 77.6566 169.178 77.7624C167.958 77.8598 167.332 78.0408 166.897 78.2636C165.85 78.7975 164.998 79.6493 164.464 80.6971C164.241 81.1315 164.06 81.7552 163.963 82.9775C163.86 84.2305 163.857 85.8454 163.857 88.2399V94.9224C163.857 97.3086 163.86 98.9319 163.963 100.185C164.063 101.404 164.244 102.031 164.464 102.465C164.998 103.512 165.85 104.364 166.897 104.899C167.332 105.122 167.958 105.303 169.178 105.4C170.431 105.5 172.046 105.503 174.44 105.503H192.26C194.646 105.503 196.27 105.503 197.522 105.4C198.742 105.3 199.369 105.119 199.803 104.899C200.85 104.364 201.702 103.512 202.236 102.465C202.459 102.031 202.64 101.404 202.738 100.182C202.841 98.9291 202.843 97.3142 202.843 94.9196V89.5792C202.843 88.8376 203.444 88.2359 204.185 88.2344C204.929 88.2328 205.533 88.8354 205.533 89.5792V94.9141C205.533 99.5918 205.533 101.925 204.623 103.713C203.822 105.283 202.545 106.56 200.975 107.36C199.193 108.271 196.854 108.271 192.177 108.271H192.177H174.357H174.357C169.679 108.271 167.346 108.271 165.558 107.36C163.99 106.556 162.714 105.281 161.91 103.713C161 101.931 161 99.5918 161 94.9141V88.2316Z" fill="currentColor"></path>
<path d="M188.685 84.17C188.292 84.17 187.915 84.3261 187.637 84.6039C187.359 84.8817 187.203 85.2585 187.203 85.6514C187.203 86.0443 187.359 86.4211 187.637 86.6989C187.915 86.9767 188.292 87.1328 188.685 87.1328L196.9 87.1268C197.293 87.1268 197.67 86.9707 197.948 86.6929C198.226 86.4151 198.382 86.0383 198.382 85.6454C198.382 85.2526 198.226 84.8758 197.948 84.5979C197.67 84.3201 197.293 84.1641 196.9 84.1641L188.685 84.17ZM188.685 90.0955C188.292 90.0955 187.915 90.2516 187.637 90.5294C187.359 90.8072 187.203 91.184 187.203 91.5769C187.203 91.9698 187.359 92.3466 187.637 92.6244C187.915 92.9022 188.292 93.0583 188.685 93.0583L196.9 93.0523C197.293 93.0523 197.67 92.8962 197.948 92.6184C198.226 92.3406 198.382 91.9638 198.382 91.5709C198.382 91.1781 198.226 90.8013 197.948 90.5234C197.67 90.2456 197.293 90.0896 196.9 90.0896L188.685 90.0955ZM187.203 97.5024C187.203 97.1095 187.359 96.7327 187.637 96.4549C187.915 96.1771 188.292 96.021 188.685 96.021L193.938 96.0151C194.33 96.0151 194.707 96.1711 194.985 96.4489C195.263 96.7268 195.419 97.1036 195.419 97.4964C195.419 97.8893 195.263 98.2661 194.985 98.5439C194.707 98.8217 194.33 98.9778 193.938 98.9778L188.685 98.9838C188.292 98.9838 187.915 98.8277 187.637 98.5499C187.359 98.2721 187.203 97.8953 187.203 97.5024Z" fill="currentColor"></path>
<path d="M181.905 84.9885C181.905 84.7655 181.635 84.6541 181.477 84.8122L178.854 87.4486C178.757 87.5462 178.757 87.7038 178.854 87.8013L180.921 89.8785C181.019 89.9767 181.178 89.9767 181.275 89.8785L181.727 89.4242C181.825 89.3259 181.984 89.3259 182.082 89.4242L182.534 89.8785C182.631 89.9767 182.79 89.9767 182.888 89.8785L184.148 88.6118C184.245 88.5143 184.245 88.3567 184.148 88.2592L181.977 86.077C181.931 86.0302 181.905 85.9668 181.905 85.9007V84.9885ZM169.179 87.8031C169.081 87.7049 168.922 87.7049 168.825 87.8031L168.371 88.2592C168.274 88.3567 168.274 88.5143 168.371 88.6118L170.542 90.794C170.588 90.8408 170.615 90.9042 170.615 90.9703V93.2536C170.615 93.2834 170.609 93.3131 170.599 93.341L169.824 95.416C169.814 95.444 169.808 95.4736 169.808 95.5035V98.7225C169.808 98.8606 169.92 98.9725 170.058 98.9725H171.171C171.309 98.9725 171.421 98.8606 171.421 98.7225V96.6401C171.421 96.5764 171.445 96.5151 171.489 96.4687L173.637 94.1879C173.685 94.1377 173.751 94.1093 173.819 94.1093L178.429 94.1093C178.567 94.1093 178.679 94.2212 178.679 94.3593V98.7225C178.679 98.8606 178.791 98.9725 178.929 98.9725H180.042C180.18 98.9725 180.292 98.8606 180.292 98.7225V90.9703C180.292 90.9042 180.265 90.8408 180.219 90.794L178.05 88.6136C177.952 88.5154 177.793 88.5154 177.695 88.6136L177.139 89.1724C177.092 89.2195 177.029 89.246 176.962 89.246H170.719C170.652 89.246 170.588 89.2195 170.541 89.1724L169.179 87.8031Z" fill="currentColor"></path>
<path d="M208.839 78.4011H204.175V83.0659C204.175 83.3133 204.076 83.5506 203.901 83.7256C203.726 83.9005 203.489 83.9988 203.242 83.9988C202.994 83.9988 202.757 83.9005 202.582 83.7256C202.407 83.5506 202.309 83.3133 202.309 83.0659V78.4011H197.644C197.396 78.4011 197.159 78.3028 196.984 78.1278C196.809 77.9529 196.711 77.7156 196.711 77.4682C196.711 77.2207 196.809 76.9834 196.984 76.8085C197.159 76.6335 197.396 76.5352 197.644 76.5352H202.309V71.8705C202.309 71.623 202.407 71.3857 202.582 71.2108C202.757 71.0358 202.994 70.9375 203.242 70.9375C203.489 70.9375 203.726 71.0358 203.901 71.2108C204.076 71.3857 204.175 71.623 204.175 71.8705V76.5352H208.839C209.087 76.5352 209.324 76.6335 209.499 76.8085C209.674 76.9834 209.772 77.2207 209.772 77.4682C209.772 77.7156 209.674 77.9529 209.499 78.1278C209.324 78.3028 209.087 78.4011 208.839 78.4011Z" fill="currentColor" stroke="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 35 35" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.3437 3.64844H13.2606C9.89333 3.64844 9.58854 3.64844 7.52937 3.92552C5.42354 4.20844 3.76104 4.80052 2.45583 6.10427C1.15063 7.40948 0.56 9.07198 0.277083 11.1793C8.69234e-08 13.237 0 15.8751 0 19.2424V19.4086C0 22.7759 8.69234e-08 25.4141 0.277083 27.4732C0.56 29.5791 1.15208 31.2416 2.45583 32.5468C3.76104 33.852 5.42354 34.4426 7.53083 34.7255C9.58854 35.0026 12.2267 35.0026 15.594 35.0026H15.7602C19.1275 35.0026 21.7656 35.0026 23.8248 34.7255C25.9306 34.4426 27.5931 33.8505 28.8983 32.5468C30.2035 31.2416 30.7942 29.5791 31.0771 27.4718C31.3542 25.4141 31.3542 25.1093 31.3542 21.742V21.6589C31.3542 21.3688 31.2389 21.0906 31.0338 20.8855C30.8287 20.6803 30.5505 20.5651 30.2604 20.5651C29.9703 20.5651 29.6921 20.6803 29.487 20.8855C29.2819 21.0906 29.1667 21.3688 29.1667 21.6589C29.1667 25.1268 29.1637 25.2843 28.91 27.1801L28.8779 27.4018L24.8325 23.7603C23.9122 22.9323 22.7381 22.441 21.5023 22.3668C20.2665 22.2926 19.0421 22.6399 18.0294 23.352L17.5948 23.6582C17.2436 23.9045 16.8168 24.019 16.3895 23.9817C15.9622 23.9444 15.5617 23.7575 15.2585 23.4541L9.00229 17.1978C8.20424 16.4001 7.13282 15.9355 6.00504 15.8981C4.87727 15.8607 3.77744 16.2534 2.92833 16.9966L2.18896 17.6426C2.19625 15.0293 2.23271 13.0445 2.44417 11.4709C2.695 9.60573 3.17333 8.48135 4.00312 7.6501C4.83437 6.82031 5.95729 6.34344 7.8225 6.0926C9.71833 5.83885 9.87583 5.83594 13.3437 5.83594C13.6338 5.83594 13.912 5.7207 14.1171 5.51559C14.3223 5.31047 14.4375 5.03227 14.4375 4.74219C14.4375 4.45211 14.3223 4.17391 14.1171 3.96879C13.912 3.76367 13.6338 3.64844 13.3437 3.64844ZM2.44417 27.1801C2.695 29.0453 3.17333 30.1697 4.00312 31.0009C4.83437 31.8307 5.95729 32.3076 7.8225 32.5584C9.71833 32.8122 12.2092 32.8151 15.6771 32.8151C19.145 32.8151 21.6358 32.8122 23.5317 32.5584C25.3969 32.3076 26.5212 31.8293 27.3525 30.9995C27.7183 30.6343 28.0165 30.2072 28.2333 29.738C28.1761 29.7015 28.1216 29.6611 28.07 29.617L23.3698 25.3878C22.8178 24.8902 22.1133 24.5947 21.3715 24.5496C20.6297 24.5046 19.8946 24.7127 19.2865 25.1399L18.8533 25.4461C18.081 25.9891 17.1418 26.2422 16.2012 26.1609C15.2606 26.0795 14.3789 25.6689 13.7113 25.0014L7.455 18.7451C7.04938 18.3396 6.50477 18.1034 5.93151 18.0844C5.35825 18.0655 4.79923 18.2652 4.36771 18.643L2.1875 20.5505C2.19187 23.3914 2.22104 25.5161 2.44417 27.1801Z"/><path d="M33.7461 10.3438H27.2812V16.8086C27.2812 17.1515 27.145 17.4804 26.9025 17.7229C26.6601 17.9653 26.3312 18.1016 25.9883 18.1016C25.6454 18.1016 25.3165 17.9653 25.074 17.7229C24.8315 17.4804 24.6953 17.1515 24.6953 16.8086V10.3438H18.2305C17.8876 10.3438 17.5587 10.2075 17.3162 9.96505C17.0737 9.72257 16.9375 9.3937 16.9375 9.05078C16.9375 8.70787 17.0737 8.37899 17.3162 8.13652C17.5587 7.89404 17.8876 7.75781 18.2305 7.75781H24.6953V1.29297C24.6953 0.950052 24.8315 0.62118 25.074 0.378701C25.3165 0.136223 25.6454 0 25.9883 0C26.3312 0 26.6601 0.136223 26.9025 0.378701C27.145 0.62118 27.2812 0.950052 27.2812 1.29297V7.75781H33.7461C34.089 7.75781 34.4179 7.89404 34.6604 8.13652C34.9028 8.37899 35.0391 8.70787 35.0391 9.05078C35.0391 9.3937 34.9028 9.72257 34.6604 9.96505C34.4179 10.2075 34.089 10.3438 33.7461 10.3438Z"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#F1EDE6"></rect><g fill="none" stroke="#B7AFA3" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="32" cy="26" r="9.5"></circle><path d="M15 51c2.8-8.2 9.2-12.4 17-12.4S46.2 42.8 49 51"></path></g></svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,9 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(6,6) scale(2.7)">
<path d="M16.1562 13.1016C16.6612 13.1016 17.0711 13.5108 17.0713 14.0156C17.0713 14.5207 16.6613 14.9307 16.1562 14.9307C15.6514 14.9304 15.2422 14.5205 15.2422 14.0156C15.2424 13.5109 15.6515 13.1018 16.1562 13.1016Z" fill="#C8C8C8" stroke="#C8C8C8" stroke-width="2"></path>
<path d="M23.8125 13.1016C24.3174 13.1016 24.7273 13.5108 24.7275 14.0156C24.7275 14.5207 24.3175 14.9307 23.8125 14.9307C23.3076 14.9304 22.8984 14.5205 22.8984 14.0156C22.8987 13.5109 23.3078 13.1018 23.8125 13.1016Z" fill="#C8C8C8" stroke="#C8C8C8" stroke-width="2"></path>
<path d="M19.9856 22.6328C19.9856 24.2189 20.4642 27.8976 17.1139 27.8976C16.3523 27.8976 15.6218 27.595 15.0833 27.0565C14.5447 26.5179 14.2422 25.7875 14.2422 25.0259" stroke="#C8C8C8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M20.0158 22.6328C20.0158 24.2189 19.5372 27.8976 22.8875 27.8976C23.6491 27.8976 24.3796 27.595 24.9181 27.0565C25.4567 26.5179 25.7592 25.7875 25.7592 25.0259" stroke="#C8C8C8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21.8992 22.3919C21.8992 23.0523 19.9848 24.067 19.9848 24.067C19.9848 24.067 18.0703 23.0523 18.0703 22.3919C18.0703 21.7314 18.5489 21.1953 19.9848 21.1953C21.4206 21.1953 21.8992 21.7314 21.8992 22.3919Z" fill="#C8C8C8" stroke="#C8C8C8" stroke-width="2" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M837.985 1121.5C843.415 1135.2 837.235 1149.83 826.265 1156.09C815.895 1162.01 796.605 1158.62 791.245 1145.06L767.155 1084.07C660.535 1072.28 555.655 1030.57 492.965 942.15C435.365 1011.81 328.705 1000.43 276.495 938.83C258.535 917.64 258.195 887.61 281.605 871.11C293.575 862.67 311.245 855.18 326.245 852.09L443.305 827.92C446.145 802.08 453.255 779.69 469.145 760.95L533.825 684.65C563.665 649.45 583.245 610.35 597.155 566.49C611.315 521.82 630.215 480.02 659.075 443.05C757.505 316.98 965.105 306.08 1076.9 420.2C1106.39 450.3 1124.05 487.04 1140.64 525.27L1224.32 571.21C1241.84 580.83 1238.92 609.06 1225.35 622.48C1210.75 636.91 1193.53 646.6 1173 657.5C1213.41 847.89 1156.79 1009.24 963.445 1068.96L978.205 1100.03C983.225 1110.59 972.615 1126.69 962.305 1130.94C952.885 1134.83 935.755 1130.23 930.475 1119.05L913.105 1082.25L824.905 1088.53L837.975 1121.51L837.985 1121.5ZM532.215 904.63L561.595 943.29C654.395 1032.41 808.865 1049.73 932.465 1022.76C987.085 1010.84 1034.49 984.7 1072.58 945.44C1137.11 870.3 1143.53 775.4 1124.24 679.19C1088.94 679.63 1066.32 654.48 1060.03 621.98C1053.47 588.11 1058.43 554.28 1084.42 530.34C1054.54 446.8 978.595 396.2 890.845 393.36C822.655 391.16 756.635 411.61 710.715 462.49C675.425 501.59 657.465 549.03 641.385 598.91C624.535 651.18 592.755 694.78 558.085 737.18L501.685 806.13C498.115 810.5 496.315 822.42 498.355 827.01C500.105 830.95 507.535 837.77 511.725 839.77C566.465 865.79 626.905 871.62 683.165 850.08C729.535 832.33 758.905 794.31 768.725 746.44C771.675 732.04 787.645 724.31 799.645 727.27C812.905 730.54 821.045 743.91 818.285 759.47C795.325 889.04 656.625 938.59 532.195 904.64L532.215 904.63ZM1172.65 598.93L1116.54 572.78C1105.56 591.18 1106.95 607.44 1118.18 626.98L1172.65 598.93ZM409.055 936.74C434.085 930.91 450.555 911.96 466.415 895.08L469.975 883.61C470.795 880.97 461.675 875.83 460.905 876.62L400.535 889.5L320.275 908.41C343.245 932.73 375.185 944.62 409.045 936.73L409.055 936.74Z" fill="black"></path>
<path d="M957.496 615.62C932.616 618.12 918.856 599.7 917.666 580.32C916.546 561.95 927.246 542.66 948.186 539.94C969.526 537.18 984.496 552.14 987.926 568.86C992.216 589.79 982.446 613.11 957.496 615.62Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1217.04 938.855C1211.06 1040.55 1145.27 1122.27 1049.98 1151.65C1008.96 1164.3 967.344 1164.49 923.644 1162.78C855.544 1205.34 789.724 1206.6 710.574 1203.64C669.154 1227.14 618.864 1228.96 587.124 1190.13C552.974 1230.92 496.274 1229.34 458.584 1196.23C405.634 1185.18 356.904 1160.92 324.664 1116.81C255.684 1022.43 278.574 880.065 345.404 784.955C264.104 699.585 266.004 573.485 326.184 477.615C317.314 429.275 315.024 381.615 329.424 335.425C341.324 292.545 379.714 270.785 422.974 282.225C462.464 292.675 496.304 314.715 529.634 339.525C602.304 312.835 679.514 315.725 753.014 338.785C787.664 311.285 823.154 290.265 863.614 281.245C905.304 271.955 939.684 293.255 951.914 333.375C966.184 380.145 964.564 428.005 954.974 477.755C1015.21 572.485 1015.14 693.165 941.884 777.955C990.344 846.455 1014.7 923.035 1007.95 1007.26C1054.06 991.155 1068.22 940.915 1045.1 900.355C1026.72 868.105 1024.27 827.595 1045.54 796.875C1061.48 773.865 1088.36 762.265 1114.91 763.665C1188.08 767.545 1221.76 858.315 1217.02 938.855H1217.04ZM756.444 1041.65C766.374 1043.54 779.393 1057.45 775.853 1069.3L751.224 1151.76C850.684 1159.32 938.374 1117.88 954.514 1015.53C965.794 943.945 944.464 871.925 902.884 812.655L862.814 835.865C849.164 843.775 833.244 833.645 828.654 820.445C825.524 811.445 829.324 795.075 840.834 789.535C889.734 765.985 927.574 725.995 941.104 672.515C956.714 610.785 940.994 547.145 906.024 495.545C896.074 480.865 921.874 424.295 904.174 352.525C892.484 305.095 823.814 346.925 781.084 383.215C770.684 392.045 757.944 395.105 744.754 390.615C678.484 368.035 607.514 364.955 541.784 389.555C525.104 395.795 512.854 394.055 498.904 382.455C467.074 355.995 410.324 319.565 388.514 334.045C367.124 348.245 369.114 423.455 377.134 468.755C379.344 481.265 377.544 494.025 370.564 504.605C305.144 603.665 327.334 733.445 436.524 787.285C448.424 793.155 454.634 807.065 452.934 817.965C451.234 828.865 436.794 843.375 424.214 837.305L385.674 818.715C314.814 920.335 306.544 1078.53 427.794 1133.85L414.334 1041.37C412.214 1026.84 430.434 1018.21 441.974 1019.53C453.884 1020.89 463.254 1031.12 465.074 1045.08C472.474 1101.9 479.084 1170.09 522.464 1169.49C559.414 1168.98 564.214 1119.72 565.874 1074.16C566.484 1057.31 579.594 1045.53 594.084 1047.67C635.514 1053.78 600.983 1109.25 624.863 1155.14C631.043 1167.02 650.274 1171.52 661.784 1168.76C671.034 1166.54 685.854 1158.07 692.074 1148.48C728.924 1091.66 716.164 1034 756.444 1041.65ZM975.534 1111.29C1025.54 1109.32 1065.1 1096.24 1099.66 1067.77C1168.23 1011.3 1186.7 908.855 1141.86 833.545C1132.54 817.885 1115.1 811.935 1100.4 816.965C1088.47 821.055 1077.32 838.835 1083.11 854.075L1096.4 889.045C1111.48 928.735 1110.93 968.475 1089.2 1005.94C1071.04 1037.24 1035.37 1058.18 995.934 1063.75L975.524 1111.28L975.534 1111.29Z" fill="black"></path>
<path d="M796.572 650.084C769.592 654.574 752.772 634.894 752.112 612.714C751.502 592.184 766.422 570.044 790.522 571.174C809.942 572.084 823.362 589.474 825.362 605.804C827.602 624.104 819.042 646.344 796.562 650.084H796.572Z" fill="black"></path>
<path d="M486.85 650.199C466.56 647.149 458.4 627.999 457.57 612.209C456.67 595.019 467 576.449 486.02 572.129C511.17 566.429 529.23 587.159 530.55 608.769C531.99 632.399 514.9 654.419 486.84 650.199H486.85Z" fill="black"></path>
<path d="M674.682 634.018C686.622 644.968 652.652 676.758 640.062 674.778C619.792 671.588 596.532 641.898 607.502 632.678C624.962 618.008 657.602 618.368 674.672 634.018H674.682Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M769.435 1177.29C758.785 1207.91 737.455 1227.47 706.555 1230.6C651.955 1236.13 596.565 1231.01 543.175 1217.86C500.055 1207.24 467.845 1178.95 446.515 1141.85C391.985 1138.38 343.025 1117.85 308.335 1078.57C259.965 1023.8 254.405 942.437 303.615 888.937C313.395 878.297 328.245 873.437 341.565 877.117C373.915 886.037 349.815 963.577 429.575 992.987C442.745 928.027 468.725 868.227 512.115 814.967C472.855 777.477 448.015 730.727 445.145 674.837C386.275 680.007 337.705 641.737 330.875 583.467C324.755 494.037 379.175 411.327 447.985 355.737C484.525 326.227 528.735 314.237 576.395 330.257C689.705 252.937 852.185 242.367 969.945 320.867C1009.38 302.087 1052.45 305.547 1089.47 330.097C1138.45 362.577 1177.31 407.327 1206.38 458.447C1223.39 492.257 1234.27 527.497 1229.66 564.587C1223.84 611.407 1188.93 644.567 1140.19 645.397C1147.29 720.517 1113.06 790.637 1050.84 833.457C1066.23 873.307 1076.51 913.697 1082.5 957.087C1096.13 978.587 1104.73 1001.51 1108.5 1027.69C1148.1 1053.64 1170.87 1097.33 1160.3 1144.08C1147.21 1201.97 1093.56 1214.41 1035.09 1206.64C1001.17 1224.8 963.445 1227.11 931.375 1200.05C914.835 1219.97 894.345 1231.11 868.845 1232.83C826.715 1235.67 791.755 1216.43 769.455 1177.28L769.435 1177.29ZM1018.43 792.987C1072.4 757.137 1099.34 695.517 1086.54 631.647C1044.17 600.127 1023.3 554.127 1011.6 503.697C1007.78 487.257 1012.99 472.257 1029.75 467.547C1043.78 463.597 1058.02 473.857 1061.87 490.377C1075.44 548.597 1110.18 612.247 1156.58 590.767C1187.79 576.327 1182.88 528.257 1163.44 489.167C1137.74 437.467 1074.31 361.597 1017.34 359.257C1023.72 376.777 1024.83 388.327 1013.49 399.057C1007.11 405.087 989.225 405.807 981.075 397.687C954.015 370.737 925.435 349.487 888.395 336.487C792.665 302.897 684.385 316.797 601.855 375.797C590.185 384.137 580.015 386.527 566.815 380.887C542.685 370.597 516.845 371.447 494.545 386.457C444.325 420.287 405.085 469.797 387.785 528.377C380.895 551.717 378.525 580.357 390.845 600.817C409.665 632.057 452.045 629.997 478.185 606.407C539.595 550.997 515.475 472.867 560.595 476.907C571.225 477.857 585.525 491.137 582.355 505.697C569.055 566.817 546.025 621.117 496.675 658.137C495.605 753.427 574.695 816.597 664.095 835.527C772.455 858.467 926.265 854.177 1018.41 792.967L1018.43 792.987ZM741.865 1065.88C740.725 1049.43 750.635 1037.53 765.725 1036.88C805.775 1035.17 782.155 1091.5 816.235 1154.3C829.715 1179.13 860.475 1190.85 884.805 1174.32C895.045 1167.37 901.115 1147.52 899.775 1134.77L889.125 1033.44C887.525 1018.17 896.945 1005.76 912.015 1002.81C921.765 1000.9 937.805 1010.18 939.255 1022.81L951.265 1127.49C953.645 1148.25 960.775 1166.59 983.725 1168.38C1000.28 1169.68 1020.08 1158.09 1025.22 1136.01C1046.14 1046.21 1037.3 948.627 1006.27 859.407C972.805 872.487 942.725 881.837 909.145 889.667C793.315 907.647 657.865 906.827 552.775 845.927C513.955 892.417 489.685 948.527 480.355 1008.41C469.505 1078.08 490.285 1153.53 560.135 1168.98C604.595 1178.82 650.055 1182.07 695.495 1180.58C711.375 1180.06 721.225 1166.4 717.885 1152.12C707.595 1108.08 648.135 1132.77 654.805 1086.37C659.245 1055.46 646.755 1027.32 620.135 1012C607.675 1004.83 604.075 988.937 609.365 977.827C614.405 967.247 630.195 960.257 641.965 966.017C683.315 986.257 705.915 1028.23 707.745 1073.05L743.805 1093.86L741.865 1065.88ZM429.255 1089.57L424.735 1044.35C377.255 1034.2 346.285 1003.28 324.955 963.167C309.695 1027.2 367.025 1077.26 429.255 1089.57ZM1109.93 1132.42C1115.28 1108.61 1102.53 1089.55 1087.61 1077.78L1074.15 1156.85C1086.47 1157.79 1106.07 1149.56 1109.92 1132.41L1109.93 1132.42Z" fill="black"></path>
<path d="M880.694 685.439C871.434 703.129 854.084 713.469 837.524 714.939C805.964 717.739 776.624 692.409 779.844 670.059C783.284 646.109 827.944 633.519 867.964 647.509C883.084 652.799 888.764 670.029 880.694 685.449V685.439Z" fill="black"></path>
<path d="M709.804 628.844C709.804 652.204 693.424 668.744 673.304 667.474C653.154 666.204 638.864 648.894 639.564 627.014C640.204 607.024 654.714 590.724 673.244 589.714C693.444 588.614 709.814 605.934 709.814 628.844H709.804Z" fill="black"></path>
<path d="M958.968 649.429C935.058 651.479 922.358 630.729 921.718 612.659C921.018 592.709 932.348 573.889 954.428 572.449C975.208 571.099 988.398 588.449 990.878 605.109C993.848 625.049 982.458 647.409 958.968 649.419V649.429Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M938.994 1081.65C905.454 1097.44 868.705 1096.21 842.174 1073.1C806.564 1042.09 832.064 1004.96 838.904 964.065C793.044 952.215 750.275 962.695 708.795 982.815L659.835 1006.56C643.985 1064.93 611.695 1124.82 550.975 1127.33C516.355 1128.76 488.934 1109.44 479.174 1076.54C449.224 1100.57 410.344 1102.82 380.674 1083.01C328.144 1047.94 371.245 979.235 347.855 921.725C317.805 880.295 289.205 839.915 269.045 792.295C258.615 767.655 239.954 756.525 215.454 748.305L140.565 723.205C106.765 711.875 80.3746 685.725 64.2546 654.405C45.0346 617.045 51.5146 578.005 77.0946 544.025L126.355 478.605C132.325 470.675 112.215 408.735 165.335 380.915C193.565 366.135 224.874 371.685 248.944 392.345C311.264 374.305 378.335 378.195 432.235 414.905C465.795 406.525 493.965 420.065 512.545 441.895C533.605 466.645 534.725 495.795 527.485 528.235C541.345 567.625 562.505 608.855 595.375 591.305L663.514 554.925C728.614 520.165 797.465 502.935 871.424 508.325C988.585 516.865 1086.23 589.565 1137.51 693.545C1153.96 726.905 1181.14 755.485 1206.81 780.135C1246.36 818.105 1295.21 830.905 1348.5 827.125C1382.91 824.685 1416.96 838.135 1435.31 866.635C1457 900.315 1446.95 943.685 1415.81 968.575C1345.83 1024.51 1224.45 1003.96 1152.57 943.445C1131.05 1038.4 1090 1132.46 990.415 1126.5C965.195 1123.35 945.905 1108.6 939.005 1081.64L938.994 1081.65ZM905.364 941.425C924.004 955.855 946.394 965.815 970.154 968.795C988.744 971.125 1001.86 985.535 996.935 1006L985.304 1054.24C984.164 1058.97 986.495 1070.37 990.255 1072.73C1011.3 1085.9 1055.11 1060.05 1071.66 1022.72C1099.09 960.865 1115.8 893.565 1113.84 824.995C1110.56 710.145 1041.42 611.875 933.864 574.605C849.404 545.335 760.085 558.735 683.645 603.075L606.255 642.275C581.365 654.885 548.415 648.465 527.525 629.945C509.735 614.175 498.195 589.795 486.085 570.175C471.535 572.405 452.225 573.005 446.835 555.795C442.325 541.395 450.504 529.295 465.614 521.875C484.534 512.585 484.104 484.095 470.514 472.005C455.194 458.385 436.034 462.645 421.764 478.865C413.434 488.325 394.775 488.745 385.295 480.225C377.365 473.085 376.934 459.465 380.654 445.635C307.724 419.155 219.555 439.935 173.345 503.375L123.585 571.675C135.445 573.595 150.464 580.595 152.444 589.315C158.374 615.455 140.514 633.255 114.434 636.175C135.094 671.295 183.705 689.065 225.515 697.735C265.125 705.945 295.124 727.585 313.684 763.495L343.824 821.825C371.254 874.915 414.755 917.495 469.965 943.515C464.905 924.645 466.125 909.435 480.605 901.575C492.745 894.985 510.294 899.925 515.844 915.895C542.914 993.875 516.105 1057.5 535.275 1072.63C551.555 1085.48 582.364 1064.33 596.094 1031.31C609.574 998.905 616.155 965.535 620.975 930.965C623.335 914.065 637.985 905.915 653.225 909.405C665.885 912.305 672.525 925.845 671.565 942.965C747.065 901.965 838.014 889.275 905.364 941.425ZM1390.52 891.045C1378.06 882.585 1360.94 877.025 1344.79 878.275C1277.78 883.465 1216.55 861.645 1167.68 815.035L1164.62 881.035C1188.9 907.345 1220.12 929.615 1255.35 941.645C1319.2 963.455 1412.17 941.345 1390.52 891.035V891.045ZM465.404 999.425L408.185 970.105L403.705 1026.78C403.235 1032.72 412.055 1043.47 417.355 1044.24C441.405 1047.73 455.865 1025.35 465.395 999.425H465.404ZM931.695 1014.81L886.534 992.255C881.904 1003.45 876.575 1020.62 875.735 1033.24C897.955 1048.89 925.885 1034.29 931.695 1014.81Z" fill="black"></path>
<path d="M274.764 602.098C260.654 597.768 252.514 580.838 252.674 568.808C252.844 556.158 260.504 540.468 273.094 535.878C296.834 527.238 316.454 544.088 318.054 564.798C319.794 587.408 302.734 610.668 274.764 602.098Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1010.4 888.934C966.177 954.204 906.647 1002.64 832.737 1030.93C847.707 1088.59 810.957 1139.24 753.887 1150.27C685.697 1161.9 622.517 1122.65 592.337 1061.41C508.417 1047.57 431.157 1016.49 369.727 961.014C248.547 853.454 237.047 699.344 342.267 575.914C400.697 512.634 477.947 473.024 562.877 455.144C589.227 400.054 639.017 361.554 693.347 351.114C757.437 338.794 820.507 360.164 862.387 406.544C889.027 436.054 895.677 474.564 898.407 510.724C942.697 541.684 984.086 574.994 1009.52 621.624C1067.77 568.764 1148.29 538.494 1201.46 590.074C1246.36 633.634 1234.63 705.884 1186.02 755.514C1216.53 786.094 1233.49 826.004 1227.08 869.304C1219.95 917.534 1177.95 949.284 1128.52 945.794C1085.04 942.714 1047.68 921.604 1010.4 888.924V888.934ZM843.707 481.604C833.567 431.344 792.277 406.754 749.557 399.024C702.217 392.464 655.557 408.134 627.397 447.414C703.187 441.894 772.597 456.044 843.707 481.594V481.604ZM958.087 874.114C1013.29 796.704 1008.44 696.864 948.766 623.014C806.596 447.044 483.827 455.624 358.887 637.154C308.267 710.694 309.537 803.594 361.567 876.044C494.687 1061.42 824.377 1061.61 958.087 874.104V874.114ZM1175.64 867.714C1184.66 830.654 1165.05 797.944 1134.37 780.144C1126.21 775.414 1118.33 764.114 1117.67 755.464C1115.82 731.214 1170.01 724.254 1178.78 666.774C1180.97 652.404 1176.39 633.394 1166.52 625.124C1129.98 594.514 1067.57 631.414 1032.96 671.664C1053.77 728.494 1052.02 783.124 1033.78 838.674C1077.87 891.374 1162.92 920.004 1175.64 867.704V867.714ZM745.087 1102.46C773.957 1096.87 790.237 1071.44 783.697 1048.1L654.197 1066.49C677.537 1093.88 711.207 1109.02 745.077 1102.46H745.087Z" fill="black"></path>
<path d="M451.798 675.916C475.798 667.606 497.808 684.806 503.198 704.556C509.338 727.046 497.218 749.176 475.568 755.816C454.248 762.346 432.028 750.666 424.668 729.826C417.368 709.166 427.208 684.416 451.788 675.906L451.798 675.916Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M957.553 1126.53C916.423 1121.02 885.323 1098.59 856.833 1069.49C766.863 1077.67 677.423 1083.26 586.113 1076.55L487.143 1064.19C479.983 1090.58 458.133 1113.17 430.793 1116.37C359.923 1124.66 306.213 1084.34 263.723 1032.37L189.533 941.626C128.333 849.796 117.863 740.756 157.013 638.226C250.683 392.926 518.783 362.276 756.053 375.076C851.853 380.246 943.623 394.596 1035.5 420.726C1064.23 385.046 1111.33 373.486 1151.44 392.506C1189.88 410.736 1212.34 452.196 1204.22 496.826C1269.37 542.096 1319.07 601.126 1349.02 672.526C1381.92 750.956 1366.58 837.096 1308.6 899.096C1276.04 933.916 1235.99 958.056 1194.14 979.486C1205.96 1005.61 1206.56 1033.05 1190.94 1056.44C1160.23 1102.44 1090.57 1093.07 1045.1 1049.33C1047.44 1097.99 1007.7 1133.26 957.563 1126.54L957.553 1126.53ZM825.733 1021C823.583 1001.63 829.863 991.086 842.003 985.686C852.593 980.976 868.233 986.506 873.743 998.906C900.073 1058.15 962.803 1091.18 987.163 1069.66C992.023 1065.37 994.973 1048.76 992.223 1042.24L973.013 996.736C966.283 980.796 975.893 964.466 990.263 960.956C1004.63 957.446 1019.52 964.786 1023.56 985.276C1079.78 970.386 1133.79 952.416 1185.83 925.506C1282.96 875.276 1345.01 792.406 1298.03 687.216C1235.34 546.856 1095.96 482.116 948.583 452.306C775.543 417.306 559.473 404.956 394.363 468.386C273.873 514.676 191.193 625.366 186.883 755.526C184.623 823.896 209.903 887.386 252.703 938.596L311.543 1009.01C369.923 1078.86 432.543 1074.57 436.513 1055.07C439.333 1041.22 407.873 1018.41 436.363 997.836C489.383 959.546 451.883 899.616 494.483 898.326C532.643 897.166 526.723 965.266 496.033 1013.17C550.883 1022.11 603.733 1030.07 658.653 1027.83L825.723 1020.99L825.733 1021ZM1149.5 464.166C1140.98 434.706 1115.15 431.706 1095.08 440.556L1149.5 464.166ZM1145.65 1030.32C1152.02 1026.54 1150.82 1006.43 1146.75 1000.43L1092.61 1019.68C1106.02 1033.42 1126.95 1041.41 1145.65 1030.32Z" fill="black"></path>
<path d="M913.54 556.115C884.92 578.015 876.16 617.115 894.91 634.185C901.84 640.495 917.68 644.775 926.83 643.525C947.67 640.695 959.75 651.435 959.92 668.935C960.08 685.835 945.86 695.195 927.81 695.465C886.99 696.095 848.24 677.405 837.39 636.365C822.91 581.575 853.02 525.275 904.72 503.325C956.17 481.485 1009.95 509.215 1025.28 562.465C1029.64 577.625 1021.93 593.675 1009 597.345C971.24 608.075 980 557.565 950.81 548.805C938.48 545.105 924.13 548.005 913.53 556.115H913.54Z" fill="black"></path>
<path d="M1127.18 738.954C1100.45 739.564 1084.55 715.505 1085.17 693.655C1085.82 670.545 1102.65 647.564 1128.48 649.044C1152.43 650.424 1167.15 671.934 1166.8 693.294C1166.45 714.164 1153.64 738.354 1127.18 738.954Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M964.546 1062C958.146 1103.67 925.836 1131.53 883.296 1126.43C853.576 1122.87 829.676 1105.27 811.636 1081.21C706.536 1087.06 604.906 1083.8 503.816 1062.65C498.806 1085.86 486.526 1102.98 470.056 1110.76C408.936 1139.61 338.106 1065.89 325.646 990.723C230.896 915.303 202.586 793.053 226.906 675.483C261.406 508.683 403.226 413.474 565.866 385.304C727.576 357.294 891.556 377.403 1042.34 441.394C1053.45 409.254 1076.49 385.883 1109.3 383.883C1145.11 381.703 1175.05 404.924 1188.96 433.164C1204.9 465.524 1197.92 500.674 1176.36 530.424C1208.51 565.134 1235.94 599.524 1255.32 641.664C1310.88 762.474 1275.68 902.434 1167.32 980.194C1179.57 1012.68 1177.02 1045.27 1154.61 1068.06C1133.26 1089.77 1100.5 1096.23 1068.66 1081.64C1053.96 1074.9 1039.77 1059.06 1028.15 1046.78L964.536 1062.03L964.546 1062ZM1193.64 878.393C1242.32 804.763 1239.33 715.073 1195.74 640.353C1125.58 520.083 987.116 463.113 854.556 438.593C866.806 463.073 875.386 483.013 878.836 506.103C881.426 523.453 867.926 535.954 853.446 535.834C817.426 535.544 833.726 484.194 803.486 465.924C787.426 456.224 768.666 467.793 761.046 478.603C751.816 491.693 750.616 510.774 761.706 523.034C782.386 545.904 813.286 550.123 800.326 578.003C796.256 586.753 782.156 594.504 770.976 590.834C728.346 576.854 696.636 534.004 702.256 488.704C705.246 464.604 719.816 441.933 743.906 425.633C611.866 419.013 455.636 445.373 361.046 535.873C277.386 615.913 249.976 739.163 286.856 848.123C306.206 905.303 346.156 947.834 400.066 973.414C514.356 1027.64 659.626 1034.19 783.956 1029.94C785.516 1012.33 793.936 1003.08 805.716 1001.37C836.176 996.963 832.436 1043.48 880.916 1073.87C886.116 1077.13 902.046 1075.58 906.226 1071.09C909.806 1067.24 913.186 1055.9 912.176 1050.24L904.916 1009.73C902.926 998.633 920.546 984.033 930.106 984.843C940.936 985.753 950.636 993.664 955.966 1009.93C1043.38 993.694 1141.68 956.973 1193.65 878.363L1193.64 878.393ZM1136.61 496.383C1147.99 478.203 1147.7 465.503 1141.61 452.723C1137.56 444.213 1123.83 433.744 1111.69 435.694C1098.31 437.834 1089.3 455.483 1090.47 466.853L1136.61 496.383ZM1121.32 1025.09L1121.82 1008.36L1080.3 1028.25C1087.15 1032.14 1095.97 1036.1 1102.9 1037.73C1108.06 1038.95 1121.16 1030.39 1121.32 1025.09ZM454.586 1051.55L400.896 1033.17C407.226 1049.79 420.336 1062.6 441.016 1066.36C446.546 1067.37 456.946 1056.07 454.586 1051.54V1051.55Z" fill="black"></path>
<path d="M964.416 701.472C966.256 726.172 950.566 743.932 930.156 744.322C908.376 744.742 890.956 727.182 891.686 703.442C892.336 682.302 906.426 664.992 926.266 664.342C946.106 663.692 962.666 677.952 964.416 701.462V701.472Z" fill="black"></path>
<path d="M1177.84 681.755C1180.7 703.305 1169.72 721.675 1151.78 724.385C1131.3 727.465 1115.02 712.985 1112.59 691.345C1110.27 670.665 1119.11 651.615 1140.21 648.825C1157.7 646.515 1174.84 659.135 1177.84 681.755Z" fill="black"></path>
<path d="M1097.37 752.186C1108.99 769.816 1087.28 787.756 1073.38 790.006C1059.01 792.326 1031.88 779.116 1037.54 759.776C1043.2 740.436 1086.38 735.506 1097.38 752.186H1097.37Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M794.056 1099.57C731.236 1104.12 668.476 1106.53 608.156 1104.78C594.876 1126.85 574.526 1144.07 553.796 1147.49C523.836 1152.43 498.806 1141.37 478.036 1122.52C458.916 1144.34 433.226 1162.87 401.556 1158.6C346.686 1151.21 329.976 1097.38 334.026 1038.63L289.706 1006.49C263.386 1000.95 242.736 991.398 229.006 974.658C210.246 951.798 211.936 924.388 214.556 898.148C183.776 886.298 161.136 862.008 164.726 827.788C167.566 800.648 181.676 777.238 201.646 755.378C167.306 726.428 163.526 682.028 191.886 648.208C210.246 626.318 236.496 611.908 264.166 598.708C243.126 563.878 251.726 522.248 286.896 502.058C316.116 485.278 348.676 478.628 383.896 474.328C376.676 434.728 399.926 402.968 438.576 395.628C474.996 388.708 511.276 396.138 547.726 406.198C549.886 369.938 578.256 347.188 613.446 348.858C653.616 350.768 689.876 366.508 727.016 387.178C739.786 349.198 776.446 331.268 813.666 345.838C845.476 358.298 870.436 381.378 894.986 407.778C916.796 383.108 948.346 376.808 975.316 393.928C1002.79 411.368 1019.96 438.348 1033.18 470.248C1063.17 452.678 1096.86 462.438 1115.9 491.998C1139.91 529.268 1138.71 577.198 1119.83 620.358C1158.76 676.508 1185.76 744.088 1251.19 752.508C1284.07 747.248 1311.77 752.238 1327.8 781.228C1341.37 806.238 1337.95 840.048 1312.72 859.168C1269.97 949.838 1179.04 998.418 1088.8 1037.44C1103.2 1073.38 1092.22 1115.27 1056.17 1129.97C1023.73 1143.2 989.426 1132.27 961.096 1110.29C954.666 1129.51 942.506 1146.13 921.696 1153.37C891.616 1163.84 855.636 1159.47 832.246 1136.72L794.056 1099.57ZM274.186 945.818C289.956 954.918 314.886 957.458 334.796 957.718C505.366 959.968 721.006 920.318 803.476 761.538C776.916 740.968 765.206 712.948 767.466 682.248C769.446 655.468 784.866 629.778 809.326 613.528C847.126 588.408 895.096 595.668 924.196 629.078L980.336 633.258C1007.67 635.288 1038.2 632.458 1059.32 613.388C1086.62 588.748 1088.24 544.308 1068.27 514.438C1044.56 515.988 1039.8 552.198 1012.69 543.918C983.316 534.948 998.296 473.408 946.636 437.068C923.286 440.878 930.496 480.868 909.016 486.628C897.216 489.788 884.276 484.588 877.886 475.078C852.906 437.878 799.066 378.078 780.966 395.438C764.806 410.938 786.066 444.148 760.166 457.838C750.846 462.758 738.256 459.458 727.876 451.558C692.126 424.368 650.986 401.048 604.886 400.198C586.956 423.428 625.876 448.128 599.496 470.148C591.446 476.868 579.146 476.828 567.516 471.218C515.926 446.328 439.976 434.618 435.626 456.458C431.876 475.278 458.726 491.048 448.376 512.898C434.896 541.348 363.486 509.598 310.396 549.768C298.206 558.988 310.606 578.138 320.956 584.148C331.916 590.508 338.926 604.568 336.806 614.888C330.096 647.578 262.736 635.568 228.276 689.268C212.346 714.088 262.476 719.848 273.186 736.228C278.046 743.668 275.346 761.388 269.396 765.998C240.706 788.218 206.016 822.768 219.986 839.918C231.486 854.028 260.286 844.318 272.986 863.978C286.806 885.378 246.446 929.838 274.176 945.838L274.186 945.818ZM878.156 1103.47C886.386 1107.95 907.656 1107.18 909.416 1097.27C916.736 1056.14 875.756 1015.17 914.686 1002.61C931.156 997.298 944.516 1006.77 950.166 1025.03C1056.99 998.448 1190.14 952.958 1254.4 858.408C1232.94 844.468 1222.57 822.478 1219.69 798.798C1183.38 785.138 1153.37 764.358 1131.63 732.708L1082.31 660.918C1040.37 688.478 994.266 686.318 948.406 683.368C942.596 695.118 932.826 705.298 921.466 704.748C889.856 703.208 898.036 662.758 869.916 652.758C845.946 644.228 825.916 660.998 820.446 682.728C815.606 701.948 828.486 720.918 850.286 728.248C863.306 732.628 873.536 741.988 875.816 753.168C879.166 769.628 862.186 774.948 853.916 783.118C753.016 955.048 574.686 999.168 382.826 1009.92C393.326 1043.02 375.136 1098.68 406.496 1106.45C450.846 1117.44 451.646 994.868 506.076 1041.28C594.046 1054.29 683.916 1056.85 772.556 1048.99C778.576 1038.41 787.406 1029.26 796.416 1028.85C825.976 1027.49 826.856 1075.57 878.156 1103.5V1103.47ZM1039.26 1078.85C1043.92 1075.14 1042.62 1059.32 1040.51 1054.22L996.746 1067.18C1003.21 1081.06 1026.47 1089.04 1039.26 1078.85Z" fill="black"></path>
<path d="M1050.23 852.388C1027.01 854.008 1012.71 834.408 1011.01 817.958C1008.77 796.388 1020.87 775.898 1044.24 774.538C1066.51 773.238 1081.52 790.438 1082.79 810.848C1083.87 828.288 1074.21 850.718 1050.23 852.398V852.388Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M388.932 949.466C254.352 934.666 93.7221 846.916 90.2321 707.796C89.0121 659.366 106.352 611.786 139.362 577.056L192.292 521.356C260.832 431.516 371.272 382.286 484.262 401.476C567.762 415.656 638.402 465.026 687.252 534.696C821.762 488.336 988.092 492.665 1096.1 591.665L1140.95 623.006C1157.79 634.776 1183.44 630.405 1196.35 614.405C1225.28 578.565 1212.69 525.116 1171.39 504.286L1115.13 483.865C1090.46 474.915 1069.19 453.886 1072.99 426.346C1077.18 395.916 1102.68 375.456 1132.69 369.066C1210.59 352.466 1289.97 381.516 1343.34 441.446C1384.15 487.276 1403.96 544.326 1408.73 605.836C1418.94 737.406 1357.38 858.486 1250.54 936.226C1267.72 970.196 1271.94 1005.26 1256.15 1036.68C1242.76 1063.33 1216.68 1076.83 1190.78 1079.45C1159.86 1082.57 1134.04 1068.41 1112.78 1044.06C1104.14 1097.75 1067.14 1135.25 1016.49 1135.55C970.012 1135.83 935.772 1105.49 935.532 1059.3C862.472 1070.67 791.002 1067.22 721.152 1055.59C692.142 1117.88 632.962 1153.06 573.672 1125.99C540.582 1110.88 525.002 1079.55 528.992 1040.5C507.262 1062.92 484.052 1075.73 457.392 1076.41C430.302 1077.11 402.882 1066.82 386.542 1042.85C368.162 1015.9 372.092 983.056 388.922 949.456L388.932 949.466ZM558.282 960.786L562.942 931.206C564.972 918.326 578.592 908.826 590.302 910.046C631.472 914.356 612.392 986.426 585.382 1035.58C579.672 1045.98 579.782 1062.52 586.172 1070.88C592.962 1079.78 608.192 1085.37 620.732 1082.89C668.812 1073.38 685.572 1013.56 689.582 962.106C690.772 946.826 701.502 935.406 715.242 935.176C727.392 934.966 742.752 947.515 741.452 961.635L737.522 1004.46C797.692 1014.34 861.632 1016.19 923.512 1007.45L919.862 969.716C918.512 955.756 933.642 943.556 945.782 943.556C979.232 943.546 966.612 984.396 980.112 1000.46C1007.04 1032.52 977.622 1057.59 992.322 1073.64C1001.33 1083.48 1019.18 1084.93 1031.42 1080.95C1068.51 1068.89 1071.77 996.896 1055.31 942.606C1050.58 927.006 1055.27 912.236 1068.12 906.646C1081.87 900.656 1099.41 906.216 1103.77 922.156L1113.63 958.156C1172.72 929.206 1229.15 894.466 1273.96 845.566C1333.44 780.636 1364.19 696.336 1356.81 609.266C1352.04 552.976 1332.17 499.906 1290.32 462.506C1245.81 422.726 1181.24 402.066 1124.44 428.946L1180.41 450.915C1254.19 479.875 1286.69 564.666 1247.2 632.786C1221.13 677.766 1168.32 692.546 1120.77 671.006C1067.84 647.026 1036.32 592.815 951.262 570.905C862.162 547.955 770.472 556.556 685.382 591.806C676.902 595.316 663.092 588.386 658.512 582.336C635.282 551.676 612.122 524.275 582.612 500.905C509.282 442.815 413.842 432.806 330.422 473.966C295.682 491.106 268.242 514.765 241.912 542.875L185.672 602.915C160.272 630.025 144.442 662.746 143.212 700.706C229.332 738.906 347.312 779.476 430.962 744.186C445.932 737.866 460.752 739.585 468.682 753.615C475.092 764.945 471.262 783.126 456.302 789.596C363.002 829.936 256.502 801.466 160.092 764.696C173.782 791.246 193.272 811.766 217.292 830.056C322.942 910.486 437.772 892.046 456.152 904.106C490.842 926.866 522.432 944.846 558.312 960.806L558.282 960.786ZM458.542 1024.38C478.132 1019.41 489.652 1007.14 498.912 992.466L443.862 959.806C433.152 974.896 423.872 991.366 428.142 1010.43C430.032 1018.88 448.832 1026.85 458.542 1024.38ZM1204.79 1020.7C1219.54 1004.43 1213.49 983.626 1206.39 967.426L1148.38 1001.33C1155.03 1011.88 1163.38 1021.75 1172.04 1026.58C1179.76 1030.88 1198.09 1028.09 1204.78 1020.7H1204.79Z" fill="black"></path>
<path d="M393.013 692.393C363.833 691.203 347.033 664.993 348.163 641.863C349.443 615.573 370.693 592.103 399.043 594.733C426.263 597.253 442.283 621.303 441.253 645.703C440.273 669.113 422.803 693.603 393.013 692.393Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M427.105 663.149C478.457 650.517 525.022 679.734 536.954 727.423C548.988 775.528 521.107 824.019 471.988 836.933C425.832 849.077 377.38 821.44 363.955 774.082C350.812 727.737 376.382 675.623 427.105 663.149Z" fill="black"></path>
<path d="M726.247 663.253C778.102 650.456 825.932 680.2 837.078 729.178C847.792 776.269 820.241 823.636 773.079 836.519C725.445 849.528 676.043 821.6 662.994 773.903C650.213 727.173 675.885 675.68 726.255 663.253H726.247Z" fill="black"></path>
<path d="M1105.23 679.211C1146.03 710.857 1149.39 768.096 1120.44 805.032C1089.67 844.271 1033.11 851.204 994.859 821.272C955.667 790.608 946.659 733.699 979.406 693.03C1009.37 655.803 1066.08 648.847 1105.23 679.219V679.211Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 842 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M870.775 1206.33C838.345 1198.74 815.105 1177.73 809.545 1145.37L690.804 1154.63C676.494 1177.91 659.194 1204.39 632.284 1215.64C588.544 1233.95 542.025 1222.86 515.155 1183.62C486.425 1199.06 457.794 1200.73 429.554 1185.04C388.194 1162.06 379.514 1112.26 395.644 1065.47C341.964 1033.08 302.734 986.544 270.664 933.164C233.124 870.684 223.165 801.654 243.605 730.894C270.375 638.224 331.525 560.884 421.005 520.784C415.235 428.434 443.525 319.134 527.715 282.834C569.245 264.924 615.215 281.834 631.235 325.104C654.835 309.324 678.644 296.734 706.974 295.984C750.404 294.834 784.084 326.824 791.074 369.264C798.284 413.054 786.995 455.194 770.005 496.954C926.375 486.234 1054.03 559.564 1117.36 700.994C1148.18 687.244 1177.73 684.674 1209.63 700.074C1267.01 727.784 1286.78 811.814 1243.01 865.264C1220.51 892.734 1185 908.334 1144.2 902.044C1135.03 964.414 1110.48 1018.93 1075.97 1071.15C1029 1142.21 962.755 1227.85 870.755 1206.33H870.775ZM542.355 485.384C552.575 442.794 568.795 409.704 588.275 374.034C596.525 358.934 583.145 330.434 565.655 328.714C504.645 322.734 466.655 431.164 473.085 500.994L542.355 485.374V485.384ZM819.414 1088.61C825.314 1080.49 832.284 1070.78 837.074 1066.77C848.974 1056.8 868.074 1059.51 875.454 1071.67C891.704 1098.45 847.005 1118.82 863.865 1143.95C883.515 1173.25 944.795 1154.54 982.525 1110.3C1074.01 1003.03 1127.75 881.214 1078.04 742.664C1025.64 596.604 889.214 526.204 738.514 551.974L685.494 612.834C676.994 622.594 658.065 620.184 649.445 613.004C642.085 606.874 637.725 589.034 645.565 580.974C677.375 548.304 705.224 515.304 723.764 473.624C742.214 432.154 753.425 368.784 724.365 351.144C696.525 334.244 652.784 367.554 628.664 406.994C572.504 498.794 598.734 572.324 557.034 567.804C544.684 566.464 535.855 556.664 532.485 540.134C493.985 547.784 456.125 558.614 421.485 578.604C317.865 638.404 251.234 777.114 302.554 881.254C349.574 976.654 408.944 1026.75 508.804 1065.55C514.974 1049.51 527.625 1041.24 542.585 1045.85C577.755 1056.7 545.215 1099.09 556.115 1147.32C559.595 1162.73 575.794 1171.9 588.784 1172.99C628.144 1176.29 649.575 1127.24 659.855 1082.8C664.095 1064.48 677.734 1054.18 696.054 1061.65C708.164 1066.59 714.344 1083.15 707.554 1101.16C743.564 1100.87 781.935 1097.76 819.405 1088.61H819.414ZM1151.42 850.874C1174.09 853.874 1192.3 846.764 1202.83 833.134C1225.54 803.754 1213.98 758.824 1182.45 744.524C1167.58 737.784 1148.76 740.314 1137.03 749.944L1151.43 850.864L1151.42 850.874ZM501.434 1118.7L440.985 1092.96C438.905 1107.35 439.175 1123.38 448.005 1134.92C454.645 1143.6 470.664 1145.76 480.284 1143.24C491.154 1140.38 500.104 1129.73 501.434 1118.7Z" fill="black"></path>
<path d="M510.514 757.112C514.014 782.272 497.754 803.112 475.534 804.072C452.804 805.052 436.294 784.132 438.054 759.582C439.484 739.552 452.895 722.902 471.355 721.412C491.185 719.812 507.275 733.882 510.505 757.122L510.514 757.112Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M891.269 731.048C953.509 697.278 1017.79 697.188 1057.91 747.078C1080.05 774.608 1086.13 811.539 1075.43 846.659C1112.25 865.359 1138.65 896.178 1146.07 930.738C1166.11 1024.04 1086.89 1084.82 996.549 1115C855.299 1162.19 702.049 1165.58 557.429 1129.39C468.789 1107.21 366.459 1054.88 352.419 962.888C343.399 903.758 372.509 850.029 426.859 827.689C420.469 786.029 434.779 748.528 463.659 724.738C492.979 700.578 531.569 696.449 574.099 709.129C514.469 596.118 535.829 458.558 649.039 392.068C732.799 342.868 834.659 332.068 926.049 365.318C1029.25 402.858 1095.99 496.838 1049.09 600.618C1082.31 635.118 1120.69 606.658 1129.17 619.818C1131.01 622.678 1131.54 633.179 1129.17 635.639C1123.77 641.269 1108.08 647.259 1099.67 649.679L1106.93 675.879C1107.93 679.488 1099.42 689.159 1095.81 688.379C1082.65 685.549 1082.64 668.448 1063 656.768L1023.7 633.389C980.719 674.009 923.919 686.798 863.739 688.268L891.259 731.028L891.269 731.048ZM717.119 599.258C726.559 591.058 741.129 591.549 751.109 600.679C804.279 649.349 901.969 650.088 965.259 615.968C1016.97 588.088 1031.24 527.369 998.249 477.699C938.879 388.329 796.379 375.828 692.829 423.548C608.389 462.448 568.949 555.479 600.329 643.149C615.419 685.299 639.489 721.648 670.199 754.858C727.299 778.078 788.079 777.358 846.819 753.868L793.599 679.078C744.019 663.208 685.929 626.328 717.119 599.248V599.258ZM1024.05 785.098C1011.67 761.348 981.159 745.578 953.889 758.308L891.479 787.429C805.709 832.079 708.039 828.528 620.939 787.258L549.769 753.528C534.139 746.118 512.659 749.958 498.639 758.988C487.269 766.308 477.359 782.158 474.869 798.258C466.059 855.208 534.579 892.478 590.729 910.728C704.909 947.848 828.889 944.758 941.029 902.478C999.099 880.588 1051.88 838.478 1024.06 785.108L1024.05 785.098ZM1097.13 939.208C1092.07 919.808 1073.59 899.388 1050.55 890.088C1023.19 916.168 994.359 934.568 958.439 948.388C843.389 992.638 716.639 997.718 597.519 964.478C542.549 949.138 478.709 919.058 444.709 874.628C411.399 887.438 392.849 922.098 401.249 958.838C416.019 1023.41 497.019 1062.3 562.649 1079.9C697.189 1115.98 840.459 1113.31 973.089 1071.58C1041.23 1050.14 1114.44 1005.54 1097.13 939.208Z" fill="black"></path>
<path d="M849.275 481.647C869.235 472.057 889.865 483.176 897.485 500.486C905.245 518.106 897.005 540.297 879.715 548.437C861.765 556.897 841.725 549.797 832.685 533.197C823.305 515.967 828.185 491.787 849.275 481.657V481.647Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="1500" height="1500" viewBox="0 0 1500 1500" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M761.86 995.977C692.87 1001.02 625.31 1002.32 556.32 999.757C547.47 1036.38 533.74 1071.92 500.68 1090.69C455.23 1116.49 394.4 1107.86 356.59 1072.14C324.94 1042.24 320.68 998.507 337.64 957.287C265.65 967.077 175.58 942.977 173.17 878.737C172.38 857.597 184.36 834.747 204.64 822.737L258.69 790.727C250.4 565.747 416.22 390.677 642.81 394.467C784.98 396.847 909.85 472.037 974.79 602.187C1013.34 546.207 1074.01 517.427 1140.37 519.547C1204.43 521.597 1265.18 554.887 1298.87 613.237C1343.35 690.287 1335.03 786.597 1272.43 850.497C1198.99 925.457 1081.79 938.657 976.59 929.867C993.57 960.297 1001.97 991.997 989.11 1026.73C966.33 1088.28 871.2 1116.34 815.78 1075.61C790.3 1056.89 774.52 1029.89 761.86 995.957V995.977ZM956.09 781.187C956.02 731.187 948.02 682.877 928.51 636.967C878.89 520.197 765.35 450.207 638.89 449.927C448.89 449.507 314.9 588.557 313.23 773.127C312.3 875.657 401.59 920.567 496.92 935.647C643.63 958.847 956.33 961.837 956.09 781.187ZM1253.26 786.617C1286.81 726.937 1274.86 650.497 1221.63 606.267C1157.7 553.147 1062.98 567.937 1018.2 636.147C1001.96 659.997 1001.8 684.917 1006.35 712.297C1015.55 767.737 1013.98 822.467 988.27 874.537C1086.64 883.447 1198.1 868.837 1253.27 786.607L1253.26 786.617ZM303.02 904.637C290.51 887.037 281.09 872.277 269.46 848.967C255.58 855.457 236.33 867.217 225.21 878.937C247.17 903.297 272.72 903.807 303.02 904.637ZM849.49 1032.18C878.91 1049.81 923.62 1037.27 937.33 1008.21C948.14 985.307 933.27 960.527 920.46 943.127C887.65 963.057 854.92 975.117 816.67 984.787C822.84 1000.73 831.89 1021.64 849.49 1032.19V1032.18ZM476.34 1040.08C492 1028.59 498.48 1005.5 500.08 992.457C460.22 985.607 428.86 977.127 392.43 965.117C385.24 981.487 376.92 1004.3 386.94 1021.56C403.51 1050.13 447.96 1060.91 476.34 1040.08Z" fill="black"></path>
<path d="M1146.57 663.005C1167.96 654.595 1188.25 664.836 1195.24 682.386C1202.41 700.366 1193.77 722.826 1177.48 729.866C1158 738.286 1137.58 730.806 1129.7 715.386C1119.99 696.386 1124.83 671.545 1146.57 663.005Z" fill="black"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+2
View File
@@ -8,6 +8,8 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Tma App</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다.</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -33,4 +33,14 @@ abstract final class AppAssets {
static const String onb3Sparkle3 = 'assets/images/onboarding/onb3_sp3.png';
static const String onb4Base = 'assets/images/onboarding/onb4_base.png';
static const String onb4Heart = 'assets/images/onboarding/onb4_ch.png';
// 공통
static const String petDefault = 'assets/svg/common/pet_default.svg';
static const String personDefault = 'assets/svg/common/person_default.svg';
static const String addPhoto = 'assets/svg/common/add_photo.svg';
static const String addCard = 'assets/svg/common/add_card.svg';
/// 종 실루엣 아이콘 — [speciesIconKey] 는 dog·cat·… (없으면 other)
static String speciesIcon(String speciesIconKey) =>
'assets/svg/species/$speciesIconKey.svg';
}
@@ -0,0 +1,32 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
const String _kPhotoDir = 'pet_photos';
/// 앨범에서 고른 사진을 앱 전용 폴더로 복사해 경로를 돌려준다.
/// (앨범 원본이 지워져도 아이 사진이 남도록) — 서버 업로드가 생기면 그쪽으로 바꾼다.
class PetPhotoStore {
const PetPhotoStore();
Future<String> persist(XFile file) async {
// 웹은 미리보기용 — 브라우저가 준 임시 주소를 그대로 쓴다
if (kIsWeb) return file.path;
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/$_kPhotoDir');
if (!dir.existsSync()) await dir.create(recursive: true);
final dot = file.name.lastIndexOf('.');
final ext = dot >= 0 ? file.name.substring(dot) : '.jpg';
// 저장 파일명은 앱이 만든다 — 원본 이름을 경로에 쓰지 않는다
final target = '${dir.path}/${DateTime.now().microsecondsSinceEpoch}$ext';
await file.saveTo(target);
return target;
}
}
final petPhotoStoreProvider = Provider<PetPhotoStore>((ref) => const PetPhotoStore());
@@ -0,0 +1,28 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
/// 기본 알레르기 항목 (시안 ALLERGENS)
const List<String> kDefaultAllergens = [
'닭고기', '소고기', '돼지고기', '유제품', '밀', '옥수수', '계란', '생선', '콩',
];
const String _kCustomAllergensKey = 'pet.customAllergens';
/// 사용자가 "직접 추가" 한 알레르기 항목 — 다음 등록 때도 메뉴에 남는다.
class AllergenMenuRepository {
AllergenMenuRepository(this._prefs);
final SharedPreferences _prefs;
List<String> load() => _prefs.getStringList(_kCustomAllergensKey) ?? const [];
Future<void> save(List<String> items) => _prefs.setStringList(
_kCustomAllergensKey,
items.where((a) => !kDefaultAllergens.contains(a)).toList(),
);
}
final allergenMenuRepositoryProvider = Provider<AllergenMenuRepository>((ref) {
return AllergenMenuRepository(ref.watch(sharedPreferencesProvider));
});
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다.
abstract interface class PetRepository {
/// 사용자가 정한 순서대로
Future<List<Pet>> fetchPets();
Future<Pet> create(PetInput input);
Future<Pet> update(String id, PetInput input);
Future<void> delete(String id);
}
const String _kPetsKey = 'pets.v1';
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
/// 서버 연동 전까지 앱을 다시 켜도 등록한 아이가 남도록 하기 위한 것이다.
class LocalPetRepository implements PetRepository {
LocalPetRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
List<Pet> _read() {
final raw = _prefs.getString(_kPetsKey);
if (raw == null) return [];
final list = jsonDecode(raw) as List<Object?>;
return list.whereType<Map<String, Object?>>().map(Pet.fromJson).toList();
}
Future<void> _write(List<Pet> pets) =>
_prefs.setString(_kPetsKey, jsonEncode([for (final p in pets) p.toJson()]));
@override
Future<List<Pet>> fetchPets() async => _read();
@override
Future<Pet> create(PetInput input) async {
final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input);
await _write([..._read(), pet]);
return pet;
}
@override
Future<Pet> update(String id, PetInput input) async {
final pet = Pet.fromInput(id, input);
await _write([for (final p in _read()) p.id == id ? pet : p]);
return pet;
}
@override
Future<void> delete(String id) async {
await _write(_read().where((p) => p.id != id).toList());
}
}
final petRepositoryProvider = Provider<PetRepository>((ref) {
return LocalPetRepository(ref.watch(sharedPreferencesProvider));
});
+161
View File
@@ -0,0 +1,161 @@
import 'package:tma_app/core/utils/date_format.dart';
enum PetGender {
male('남아'),
female('여아');
const PetGender(this.label);
final String label;
}
enum WeightUnit {
kg('킬로그램 (kg)'),
g('그램 (g)');
const WeightUnit(this.label);
/// 단위 선택 시트에 보이는 이름
final String label;
}
/// 등록·수정 폼에서 받는 값 (서버가 id 를 붙이기 전)
class PetInput {
const PetInput({
required this.name,
required this.species,
required this.breed,
required this.gender,
required this.neutered,
required this.birthDate,
required this.weight,
required this.weightUnit,
this.photoPath,
this.bloodType,
this.vet,
this.memo,
this.allergies = const [],
this.conditions = const [],
this.watchConditions = const [],
});
final String name;
/// 대분류 — 강아지·고양이 … (PetCategory.id)
final String species;
/// 세부 품종 — 고르지 않았으면 빈 문자열
final String breed;
final PetGender gender;
final bool neutered;
final DateTime birthDate;
final double weight;
final WeightUnit weightUnit;
/// 기기에 저장한 사진 경로 — 없으면 기본 아이콘
final String? photoPath;
/// 값이 없으면 null ("미등록" 표기는 화면에서 한다)
final String? bloodType;
final String? vet;
final String? memo;
final List<String> allergies;
/// 현재 보유 질환
final List<String> conditions;
/// 염려되는 질환 (주의가 필요한 항목)
final List<String> watchConditions;
}
/// 등록된 반려동물
class Pet extends PetInput {
const Pet({
required this.id,
required super.name,
required super.species,
required super.breed,
required super.gender,
required super.neutered,
required super.birthDate,
required super.weight,
required super.weightUnit,
super.photoPath,
super.bloodType,
super.vet,
super.memo,
super.allergies,
super.conditions,
super.watchConditions,
});
factory Pet.fromInput(String id, PetInput input) => Pet(
id: id,
name: input.name,
species: input.species,
breed: input.breed,
gender: input.gender,
neutered: input.neutered,
birthDate: input.birthDate,
weight: input.weight,
weightUnit: input.weightUnit,
photoPath: input.photoPath,
bloodType: input.bloodType,
vet: input.vet,
memo: input.memo,
allergies: input.allergies,
conditions: input.conditions,
watchConditions: input.watchConditions,
);
final String id;
/// 3.2kg · 450g — 소수점 끝의 0 은 떼어 낸다
String get weightLabel {
final text = weight == weight.roundToDouble()
? weight.toStringAsFixed(0)
: weight.toString();
return '$text${weightUnit.name}';
}
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'species': species,
'breed': breed,
'gender': gender.name,
'neutered': neutered,
'birthDate': toIsoDate(birthDate),
'weight': weight,
'weightUnit': weightUnit.name,
'photoPath': photoPath,
'bloodType': bloodType,
'vet': vet,
'memo': memo,
'allergies': allergies,
'conditions': conditions,
'watchConditions': watchConditions,
};
factory Pet.fromJson(Map<String, Object?> json) => Pet(
id: json['id']! as String,
name: json['name']! as String,
species: json['species']! as String,
breed: json['breed'] as String? ?? '',
gender: PetGender.values.byName(json['gender']! as String),
neutered: json['neutered']! as bool,
birthDate: parseIsoDate(json['birthDate']! as String),
weight: (json['weight']! as num).toDouble(),
weightUnit: WeightUnit.values.byName(json['weightUnit']! as String),
photoPath: json['photoPath'] as String?,
bloodType: json['bloodType'] as String?,
vet: json['vet'] as String?,
memo: json['memo'] as String?,
allergies: _stringList(json['allergies']),
conditions: _stringList(json['conditions']),
watchConditions: _stringList(json['watchConditions']),
);
}
List<String> _stringList(Object? value) =>
value is List ? value.whereType<String>().toList() : const [];
@@ -0,0 +1,10 @@
/// 생년월일로 나이 표기 — 1살 이상이면 "N살 M개월", 아니면 "M개월".
/// 이번 달의 생일 날짜가 아직 안 왔으면 한 달을 덜 센다 (시안 computeAge).
String petAgeLabel(DateTime birth, DateTime today) {
var months = (today.year - birth.year) * 12 + (today.month - birth.month);
if (today.day < birth.day) months--;
if (months < 0) months = 0;
final years = months ~/ 12;
final rest = months % 12;
return years >= 1 ? '$years살 $rest개월' : '$rest개월';
}
@@ -0,0 +1,90 @@
/// 반려동물 대분류 + 세부 품종 (시안 pet-species.jsx)
class PetCategory {
const PetCategory(this.id, this.iconKey, this.breeds);
/// 화면에 보이는 이름이자 저장값 — 강아지·고양이 …
final String id;
/// 실루엣 아이콘 파일 이름 (`assets/svg/species/{iconKey}.svg`)
final String iconKey;
/// 세부 품종 — 맨 앞에 [kUnknownBreed] 를 붙여서 보여 준다
final List<String> breeds;
/// 선택 목록 — 종을 모르는 경우를 맨 위에 둔다
List<String> get choices => [kUnknownBreed, ...breeds];
}
/// 종을 정확히 모르거나 잡종인 경우 — 모든 대분류 맨 위에 공통으로 둔다
const String kUnknownBreed = '나만의 아이 🩷 (종 모름/기타)';
/// 위 항목을 고르면 저장·표시되는 값 (괄호 설명은 고를 때만 보인다)
const String kUnknownBreedValue = '나만의 아이 🩷';
/// 고르면 세부 품종을 비워 두는 항목
const String kCustomBreed = '직접 입력';
const String kDefaultSpecies = '강아지';
/// 목록에서 고른 항목 → 저장값
String breedValueOf(String choice) => switch (choice) {
kCustomBreed => '',
kUnknownBreed => kUnknownBreedValue,
_ => choice,
};
const List<PetCategory> kPetCategories = [
PetCategory('강아지', 'dog', [
'말티즈', '푸들 (토이)', '푸들 (미니어처)', '푸들 (스탠다드)', '포메라니안', '시츄', '요크셔테리어', '치와와', '비숑 프리제',
'닥스훈트', '웰시코기 (펨브로크)', '웰시코기 (카디건)', '시바견', '진돗개', '풍산개', '삽살개', '파피용', '페키니즈', '라사압소',
'코통 드 툴레아', '재패니즈 스피츠', '폼피츠', '말티푸', '코카푸', '골든두들', '믹스견',
'비글', '코커 스패니얼', '스프링거 스패니얼', '보더콜리', '셰틀랜드 쉽독', '오스트레일리안 셰퍼드', '콜리',
'슈나우저 (미니어처)', '슈나우저 (스탠다드)', '슈나우저 (자이언트)', '프렌치 불독', '잉글리시 불독', '보스턴테리어', '퍼그',
'잭 러셀 테리어', '웨스트 하이랜드 화이트테리어', '스코티시테리어', '케언테리어', '불테리어', '폭스테리어', '에어데일테리어', '미니어처 핀셔',
'카발리에 킹 찰스 스패니얼', '달마시안', '차우차우', '아키타견',
'골든 리트리버', '래브라도 리트리버', '저먼 셰퍼드', '도베르만', '로트와일러', '복서', '시베리안 허스키', '알래스칸 말라뮤트',
'사모예드', '버니즈 마운틴독', '그레이트 피레니즈', '그레이트 데인', '세인트 버나드', '마스티프', '뉴펀들랜드', '티베탄 마스티프',
'그레이하운드', '휘핏', '보르조이', '아프간 하운드', '살루키', '바셋하운드', '블러드하운드', '와이마라너', '비즐라', '포인터', '바센지',
'아메리칸 핏불테리어', '아메리칸 불리', '스타포드셔 불테리어', '이탈리안 그레이하운드',
]),
PetCategory('고양이', 'cat', [
'코리안 숏헤어', '페르시안', '러시안 블루', '브리티시 숏헤어', '브리티시 롱헤어', '스코티시 폴드', '스코티시 스트레이트',
'먼치킨', '먼치킨 나폴레옹', '하이랜드 폴드', '아메리칸 숏헤어', '아메리칸 컬', '샴', '노르웨이 숲', '메인쿤', '랙돌', '벵갈', '아비시니안', '소말리',
'스핑크스', '피터볼드', '데본렉스', '코니시렉스', '셀커크렉스', '라팜', '터키시 앙고라', '터키시 반', '버만', '봄베이',
'샤트룩스', '싱가퓨라', '오시캣', '이그조틱 숏헤어', '히말라얀', '발리니즈', '통키니즈', '코랫', '맹크스', '시베리안',
'오리엔탈 숏헤어', '재패니즈 밥테일', '이집트 마우', '네벨룽', '토이거', '사바나', '하바나 브라운', '버미즈', '친칠라 (고양이)', '믹스묘',
]),
PetCategory('토끼', 'rabbit', [
'네덜란드 드워프', '홀랜드 롭', '미니 롭', '잉글리시 롭', '프렌치 롭', '라이언헤드', '렉스', '미니 렉스', '앙고라',
'저지 울리', '더치', '히말라얀 래빗', '친칠라 래빗', '폴리시', '플레미시 자이언트', '믹스 토끼',
]),
PetCategory('햄스터', 'hamster', [
'골든 햄스터 (시리안)', '테디베어 햄스터', '드워프 캠벨', '정글리안 (윈터화이트)', '로보로브스키', '차이니즈 햄스터',
]),
PetCategory('기니피그', 'guineapig', [
'아메리칸', '아비시니안', '페루비안', '셸티', '텍셀', '코로넷', '화이트 크레스티드', '스킨니 (무모)',
]),
PetCategory('페럿', 'ferret', ['세이블', '초콜릿', '시나몬', '알비노', '블랙 세이블', '패널 (다크아이드 화이트)']),
PetCategory('고슴도치', 'hedgehog', ['아프리칸 피그미', '솔트앤페퍼', '시나몬', '알비노', '초콜릿', '파이드']),
PetCategory('새', 'bird', [
'사랑앵무 (버지)', '왕관앵무 (코카티엘)', '모란앵무 (러브버드)', '회색앵무', '코뉴어', '뉴기니아 앵무', '로리키트',
'아마존앵무', '카이큐', '마카우 (금강앵무)', '카나리아', '문조', '금화조', '십자매', '잉꼬', '왕관비둘기',
]),
PetCategory('도마뱀', 'lizard', [
'레오파드 게코', '크레스티드 게코', '턱수염도마뱀 (비어디드 드래곤)', '토케이 게코', '자이언트 게코', '블루텅 스킨크',
'이구아나', '카멜레온', '사바나 모니터', '차이니즈 워터 드래곤', '우로마스틱스', '아르마딜로 도마뱀',
]),
PetCategory('거북이', 'turtle', [
'붉은귀거북', '노란배거북', '비단거북 (페인티드)', '사향거북 (머스크)', '지도거북', '늪거북',
'러시안 육지거북', '헤르만 육지거북', '설카타 육지거북', '별거북', '레오파드 육지거북',
]),
PetCategory('뱀', 'snake', ['콘스네이크', '볼파이톤', '밀크스네이크', '킹스네이크', '가터스네이크', '호그노즈', '보아뱀']),
PetCategory('물고기', 'fish', [
'베타', '구피', '금붕어', '네온테트라', '구라미', '몰리', '플래티', '엔젤피시', '디스커스', '코리도라스',
'플레코 (안시스트루스)', '시클리드', '아피스토그라마', '라스보라', '제브라다니오', '킬리피시', '잉어 (코이)', '아로와나',
]),
PetCategory('기타', 'other', ['슈가글라이더', '친칠라', '미니피그', '다람쥐', '거미 (타란튤라)', '달팽이', kCustomBreed]),
];
PetCategory categoryOf(String id) =>
kPetCategories.firstWhere((c) => c.id == id, orElse: () => kPetCategories.last);
@@ -0,0 +1,472 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/allergen_selector.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_form_parts.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_pick_sheets.dart';
import 'package:tma_app/shared/widgets/date_picker_sheet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_nav_header.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 등록 화면 입력 항목 사이 간격 — 시안 .rup-reg .rup-field { margin-bottom: 22px }
const double _kFieldGap = 22;
const double _kColumnGap = 24;
/// 사진 선택 시 줄이는 크기 — 원본 그대로 두면 저장 공간을 많이 쓴다
const double _kPhotoMaxSide = 1600;
const int _kPhotoQuality = 85;
/// 빠진 칸으로 스크롤할 때 화면 위쪽 여유
const double _kScrollAlignment = .1;
/// 쉼표·가운뎃점으로 나눈 목록 ("피부염, 슬개골" → [피부염, 슬개골])
List<String> _splitList(String text) =>
text.split(RegExp('[,·、]')).map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
String? _nullIfBlank(String text) => text.trim().isEmpty ? null : text.trim();
/// 반려동물 등록 / 수정 — [petId] 가 있으면 수정 모드
class PetRegisterPage extends ConsumerStatefulWidget {
const PetRegisterPage({super.key, this.petId});
final String? petId;
@override
ConsumerState<PetRegisterPage> createState() => _PetRegisterPageState();
}
class _PetRegisterPageState extends ConsumerState<PetRegisterPage> {
late final Pet? _editing =
widget.petId == null ? null : ref.read(petByIdProvider(widget.petId!));
late final _name = TextEditingController(text: _editing?.name);
late final _weight = TextEditingController(
text: _editing == null ? '' : _editing.weightLabel.replaceAll(RegExp('[a-z]'), ''),
);
late final _conditions = TextEditingController(text: _editing?.conditions.join(', '));
late final _watch = TextEditingController(text: _editing?.watchConditions.join(', '));
late final _vet = TextEditingController(text: _editing?.vet);
late final _blood = TextEditingController(text: _editing?.bloodType);
late final _memo = TextEditingController(text: _editing?.memo);
late String _species = _editing?.species ?? kDefaultSpecies;
late String _breed = _editing?.breed ?? '';
late bool _speciesPicked = _editing != null;
late PetGender _gender = _editing?.gender ?? PetGender.male;
late bool _neutered = _editing?.neutered ?? true;
late DateTime? _birth = _editing?.birthDate;
late WeightUnit _unit = _editing?.weightUnit ?? WeightUnit.kg;
late String? _photoPath = _editing?.photoPath;
late final Set<String> _allergies = {...?_editing?.allergies};
late final List<String> _customMenu = _initialCustomMenu();
bool _showErrors = false;
bool _saving = false;
// 빠진 칸으로 스크롤하기 위한 위치 표식
final _nameKey = GlobalKey();
final _speciesKey = GlobalKey();
final _birthKey = GlobalKey();
final _weightKey = GlobalKey();
List<String> _initialCustomMenu() {
final saved = ref.read(allergenMenuRepositoryProvider).load();
// 이 아이의 알레르기 중 기본·저장 목록에 없는 항목도 메뉴에 보여 준다
final extra = (_editing?.allergies ?? const <String>[])
.where((a) => !kDefaultAllergens.contains(a) && !saved.contains(a));
return [...saved, ...extra];
}
@override
void dispose() {
for (final c in [_name, _weight, _conditions, _watch, _vet, _blood, _memo]) {
c.dispose();
}
super.dispose();
}
bool get _nameMissing => _name.text.trim().isEmpty;
bool get _weightMissing => double.tryParse(_weight.text) == null;
bool get _birthMissing => _birth == null;
bool get _speciesMissing => !_speciesPicked;
bool get _basicFilled => !_nameMissing && !_weightMissing && !_birthMissing && !_speciesMissing;
// ── 선택 시트 ─────────────────────────────────────────
Future<void> _pickSpecies() async {
final choice = await showSpeciesBreedPicker(context, species: _species, breed: _breed);
if (choice == null || !mounted) return;
setState(() {
_species = choice.species;
_breed = choice.breed;
_speciesPicked = true;
});
}
Future<void> _pickBirth() async {
final date = await showBirthDatePicker(context, initial: _birth);
if (date == null || !mounted) return;
setState(() => _birth = date);
}
Future<void> _pickUnit() async {
final unit = await showWeightUnitPicker(context, current: _unit);
if (unit == null || !mounted) return;
setState(() => _unit = unit);
}
Future<void> _pickPhoto() async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: _kPhotoMaxSide,
maxHeight: _kPhotoMaxSide,
imageQuality: _kPhotoQuality,
);
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
if (!mounted) return;
setState(() => _photoPath = path);
}
// ── 알레르기 ───────────────────────────────────────────
void _toggleAllergen(String a) => setState(() {
if (!_allergies.remove(a)) _allergies.add(a);
});
void _addCustomAllergen(String a) {
setState(() {
if (!kDefaultAllergens.contains(a) && !_customMenu.contains(a)) _customMenu.add(a);
_allergies.add(a);
});
unawaited(ref.read(allergenMenuRepositoryProvider).save(_customMenu));
}
void _removeCustomAllergen(String a) {
setState(() {
_customMenu.remove(a);
_allergies.remove(a);
});
unawaited(ref.read(allergenMenuRepositoryProvider).save(_customMenu));
}
// ── 저장 ──────────────────────────────────────────────
PetInput _buildInput() => PetInput(
name: _name.text.trim(),
species: _species,
breed: _breed,
gender: _gender,
neutered: _neutered,
birthDate: _birth!,
weight: double.parse(_weight.text),
weightUnit: _unit,
photoPath: _photoPath,
bloodType: _nullIfBlank(_blood.text),
vet: _nullIfBlank(_vet.text),
memo: _nullIfBlank(_memo.text),
allergies: _allergies.toList(),
conditions: _splitList(_conditions.text),
watchConditions: _splitList(_watch.text),
);
/// 빠진 기본 정보가 있으면 첫 번째 빠진 칸으로 스크롤해 짚어 준다
void _revealFirstMissing() {
final order = [
(_nameMissing, _nameKey),
(_speciesMissing, _speciesKey),
(_birthMissing, _birthKey),
(_weightMissing, _weightKey),
];
final target = order.where((e) => e.$1).firstOrNull?.$2.currentContext;
if (target == null) return;
unawaited(Scrollable.ensureVisible(
target,
alignment: _kScrollAlignment,
duration: AppDurations.fade,
curve: Curves.easeOutCubic,
));
}
Future<void> _submit() async {
if (_saving) return;
if (!_basicFilled) {
setState(() => _showErrors = true);
_revealFirstMissing();
return;
}
setState(() => _saving = true);
final notifier = ref.read(petListProvider.notifier);
final toast = ref.read(toastProvider.notifier);
try {
final editing = _editing;
if (editing == null) {
final pet = await notifier.create(_buildInput());
ref.read(selectedPetIdProvider.notifier).select(pet.id);
toast.show('${pet.name} 등록 완료!');
} else {
await notifier.updatePet(editing.id, _buildInput());
toast.show('반려동물 정보를 저장했어요');
}
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _delete() async {
final editing = _editing;
if (editing == null) return;
final ok = await showRupConfirm(
context,
title: '${withObjectParticle(editing.name)} 삭제할까요?',
message: '아이의 일정과 펫 명함도 함께 삭제되고\n되돌릴 수 없어요.',
confirmLabel: '삭제하기',
destructive: true,
);
if (!ok || !mounted) return;
await ref.read(petListProvider.notifier).deletePet(editing.id);
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
if (mounted) context.go(AppRoutes.myPage);
}
// ── 화면 ──────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final editing = _editing;
final err = _showErrors;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RupNavHeader(
title: editing == null ? '반려동물 등록' : '반려동물 정보 수정',
subtitle: editing == null ? null : '${editing.name}의 정보를 업데이트해요',
onBack: () => context.pop(),
),
const SizedBox(height: 18),
PetPhotoSlot(photoPath: _photoPath, onPick: _pickPhoto),
const SizedBox(height: AppSpacing.xs),
const PetFormSectionLabel(text: '기본 정보', required: true),
RupField(
key: _nameKey,
label: '이름',
errorText: err && _nameMissing ? '이름을 입력해주세요.' : null,
child: RupTextField(
controller: _name,
hintText: '아이 이름을 입력해주세요',
hasError: err && _nameMissing,
textInputAction: TextInputAction.next,
onChanged: (_) => setState(() {}),
),
),
const SizedBox(height: _kFieldGap),
RupField(
key: _speciesKey,
label: '종',
errorText: err && _speciesMissing ? '종을 선택해주세요.' : null,
child: RupPickerField(
onTap: _pickSpecies,
hasError: err && _speciesMissing,
semanticLabel: '종 선택',
trailing: const RupIcon(RupIconName.chevR, size: 18, color: AppColors.muted),
child: _speciesPicked
? Text.rich(
TextSpan(children: [
TextSpan(
text: _species,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' · ${_breed.isEmpty ? '세부 품종 선택' : _breed}',
style: TextStyle(
color: _breed.isEmpty ? AppColors.muted : AppColors.ink2,
),
),
]),
)
: const Text('종을 선택해주세요', style: TextStyle(color: AppColors.muted)),
),
),
const SizedBox(height: _kFieldGap),
Row(
children: [
Expanded(
child: RupField(
label: '성별',
child: RupSegmented<PetGender>(
options: [for (final g in PetGender.values) (g, g.label)],
value: _gender,
onChanged: (g) => setState(() => _gender = g),
),
),
),
const SizedBox(width: _kColumnGap),
Expanded(
child: RupField(
label: '중성화',
child: RupSegmented<bool>(
options: const [(true, '완료'), (false, '안 함')],
value: _neutered,
onChanged: (v) => setState(() => _neutered = v),
),
),
),
],
),
const SizedBox(height: 18),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: RupField(
key: _birthKey,
label: '생년월일',
errorText: err && _birthMissing ? '생년월일을 선택해주세요.' : null,
child: RupPickerField(
onTap: _pickBirth,
hasError: err && _birthMissing,
semanticLabel: '생년월일 선택',
child: Text(
_birth == null ? '생년월일 선택' : formatYmdSpaced(_birth!),
style: TextStyle(color: _birth == null ? AppColors.muted : AppColors.ink),
),
),
),
),
const SizedBox(width: _kColumnGap),
Expanded(
child: RupField(
key: _weightKey,
label: '몸무게',
errorText: err && _weightMissing ? '몸무게를 입력해주세요.' : null,
child: WeightInput(
controller: _weight,
unit: _unit,
onUnitTap: _pickUnit,
hasError: err && _weightMissing,
onChanged: (_) => setState(() {}),
),
),
),
],
),
const SizedBox(height: _kFieldGap),
const PetFormSectionLabel(text: '건강 정보', top: 14),
RupField(
label: '현재 보유 질환',
child: RupTextField(controller: _conditions, hintText: '현재 앓고 있는 질환을 입력해주세요'),
),
const SizedBox(height: _kFieldGap),
RupField(
label: '염려되는 질환',
labelSuffix: '(주의가 필요한 항목)',
child: RupTextField(controller: _watch, hintText: '주의가 필요한 질환을 입력해주세요'),
),
const SizedBox(height: _kFieldGap),
Row(
children: [
Expanded(
child: RupField(
label: '담당 병원',
child: RupTextField(controller: _vet, hintText: '병원 이름'),
),
),
const SizedBox(width: 10),
Expanded(
child: RupField(
label: '혈액형',
child: RupTextField(controller: _blood, hintText: '혈액형을 입력해주세요'),
),
),
],
),
const SizedBox(height: _kFieldGap),
RupField(
label: '알레르기',
child: AllergenSelector(
selected: _allergies,
customMenu: _customMenu,
onToggle: _toggleAllergen,
onAddCustom: _addCustomAllergen,
onRemoveCustom: _removeCustomAllergen,
),
),
const PetFormSectionLabel(text: '보호자 메모'),
RupTextField(
controller: _memo,
multiline: true,
hintText: '성격, 진료 시 참고사항 등을 적어주세요.',
),
const SizedBox(height: _kFieldGap + AppSpacing.sm),
RupButton(
label: editing == null ? '반려동물 등록' : '변경사항 저장',
large: true,
thin: true,
dimmed: !_basicFilled,
onPressed: _saving ? null : _submit,
),
if (editing != null)
Padding(
padding: const EdgeInsets.only(top: 22),
child: TextButton(
onPressed: _delete,
child: Text(
'삭제하기',
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.muted,
),
),
),
),
if (!_basicFilled)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'기본 정보를 모두 입력해주세요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: err ? AppColors.error : AppColors.muted,
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
/// 등록된 반려동물 목록
class PetListNotifier extends AsyncNotifier<List<Pet>> {
PetRepository get _repo => ref.read(petRepositoryProvider);
@override
Future<List<Pet>> build() => ref.watch(petRepositoryProvider).fetchPets();
Future<Pet> create(PetInput input) async {
final pet = await _repo.create(input);
if (ref.mounted) state = AsyncData([...?state.value, pet]);
return pet;
}
Future<Pet> updatePet(String id, PetInput input) async {
final pet = await _repo.update(id, input);
if (ref.mounted) {
state = AsyncData([for (final p in state.value ?? const <Pet>[]) p.id == id ? pet : p]);
}
return pet;
}
Future<void> deletePet(String id) async {
await _repo.delete(id);
if (ref.mounted) {
state = AsyncData([...?state.value?.where((p) => p.id != id)]);
}
}
}
final petListProvider = AsyncNotifierProvider<PetListNotifier, List<Pet>>(PetListNotifier.new);
/// 홈에서 고른 아이 id
class SelectedPetIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) => state = id;
}
final selectedPetIdProvider = NotifierProvider<SelectedPetIdNotifier, String?>(
SelectedPetIdNotifier.new,
);
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이
final selectedPetProvider = Provider<Pet?>((ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final id = ref.watch(selectedPetIdProvider);
return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull;
});
/// 반려동물 id 로 찾기 — 수정 화면 등
final petByIdProvider = Provider.family<Pet?, String>((ref, id) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
return pets.where((p) => p.id == id).firstOrNull;
});
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/data/repositories/allergen_menu_repository.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
const int _kCustomMaxLength = 12;
const double _kCustomInputWidth = 76;
/// 알레르기 선택 — 기본 항목 + 직접 추가한 항목(메뉴에서 지울 수 있음) + 직접 추가
class AllergenSelector extends StatefulWidget {
const AllergenSelector({
super.key,
required this.selected,
required this.customMenu,
required this.onToggle,
required this.onAddCustom,
required this.onRemoveCustom,
});
final Set<String> selected;
/// 직접 추가해 둔 항목
final List<String> customMenu;
final ValueChanged<String> onToggle;
final ValueChanged<String> onAddCustom;
final ValueChanged<String> onRemoveCustom;
@override
State<AllergenSelector> createState() => _AllergenSelectorState();
}
class _AllergenSelectorState extends State<AllergenSelector> {
bool _adding = false;
final _input = TextEditingController();
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _commit() {
final value = _input.text.trim();
if (value.isNotEmpty) widget.onAddCustom(value);
_input.clear();
setState(() => _adding = false);
}
@override
Widget build(BuildContext context) {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
for (final a in kDefaultAllergens)
RupTagChip(
label: a,
selected: widget.selected.contains(a),
onTap: () => widget.onToggle(a),
),
for (final a in widget.customMenu)
RupTagChip(
label: a,
selected: widget.selected.contains(a),
onTap: () => widget.onToggle(a),
onDelete: () => widget.onRemoveCustom(a),
),
if (_adding)
_CustomInputChip(controller: _input, onSubmit: _commit)
else
RupAddChip(label: '직접 추가', onTap: () => setState(() => _adding = true)),
],
);
}
}
class _CustomInputChip extends StatelessWidget {
const _CustomInputChip({required this.controller, required this.onSubmit});
final TextEditingController controller;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
);
return Container(
padding: const EdgeInsets.fromLTRB(11, 3, 5, 3),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.primary, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: _kCustomInputWidth,
child: TextField(
controller: controller,
autofocus: true,
maxLength: _kCustomMaxLength,
style: style,
cursorColor: AppColors.primary,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSubmit(),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
counterText: '',
hintText: '직접 입력',
hintStyle: style.copyWith(color: AppColors.muted),
contentPadding: EdgeInsets.zero,
),
),
),
const SizedBox(width: 5),
Semantics(
button: true,
label: '추가',
child: GestureDetector(
onTap: onSubmit,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(AppSpacing.sm),
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.check, size: 15, stroke: 2.6, color: Colors.white),
),
),
),
],
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
/// 사진이 없을 때 원 테두리 — 시안 .rup-avatar--slot
const Color _kEmptyRing = Color(0xFFC8C8C8);
/// 반려동물 원형 아바타 — 사진이 없으면 기본 강아지 얼굴 아이콘
class PetAvatar extends StatelessWidget {
const PetAvatar({super.key, required this.pet, required this.size});
final Pet pet;
final double size;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
return Semantics(
label: '${pet.name} 사진',
image: true,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
),
clipBehavior: Clip.antiAlias,
child: photo == null
? SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover)
: PetPhotoImage(path: photo),
),
);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
/// 몸무게 단위 버튼 폭 — 시안 72
const double _kUnitButtonWidth = 72;
/// 몸무게 — 숫자와 소수점 하나, 소수 둘째 자리까지
final TextInputFormatter weightInputFormatter = TextInputFormatter.withFunction(
(oldValue, newValue) =>
RegExp(r'^\d{0,4}(\.\d{0,2})?$').hasMatch(newValue.text) ? newValue : oldValue,
);
/// 폼 섹션 제목 — "기본 정보 ●" (필수 섹션은 주황 점)
class PetFormSectionLabel extends StatelessWidget {
const PetFormSectionLabel({super.key, required this.text, this.required = false, this.top = 30});
final String text;
final bool required;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: 18),
child: Row(
children: [
Text(
text,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
if (required) ...[
const SizedBox(width: 9),
Semantics(
label: '필수',
child: Container(
width: AppSpacing.sm,
height: AppSpacing.sm,
decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle),
),
),
],
],
),
);
}
}
/// 몸무게 입력 + 단위 버튼
class WeightInput extends StatelessWidget {
const WeightInput({
super.key,
required this.controller,
required this.unit,
required this.onUnitTap,
required this.hasError,
required this.onChanged,
});
final TextEditingController controller;
final WeightUnit unit;
final VoidCallback onUnitTap;
final bool hasError;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: RupTextField(
controller: controller,
hintText: '무게 입력',
hasError: hasError,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [weightInputFormatter],
onChanged: onChanged,
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: _kUnitButtonWidth,
child: RupPickerField(
onTap: onUnitTap,
semanticLabel: '몸무게 단위 ${unit.name}',
trailing: const RupIcon(RupIconName.chevD, size: 16, color: AppColors.muted),
child: Text(
unit.name,
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink),
),
),
),
],
);
}
}
@@ -0,0 +1,22 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
/// 기기에 저장된 반려동물 사진 — 웹 미리보기에서는 브라우저 임시 주소를 쓴다
class PetPhotoImage extends StatelessWidget {
const PetPhotoImage({super.key, required this.path, this.fit = BoxFit.cover});
final String path;
final BoxFit fit;
@override
Widget build(BuildContext context) {
// 사진 파일이 지워졌거나 읽을 수 없으면 빈 칸으로 둔다
Widget error(BuildContext context, Object e, StackTrace? s) => const SizedBox.shrink();
if (kIsWeb) {
return Image.network(path, fit: fit, errorBuilder: error);
}
return Image.file(File(path), fit: fit, errorBuilder: error);
}
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 등록 화면 사진 칸 높이·모서리 — 시안 image-slot (200, radius 16)
const double _kSlotHeight = 200;
const double _kSlotRadius = 16;
const double _kEditBadge = 34;
/// 반려동물 사진 칸 — 비어 있으면 "우리 아이 사진 추가하기", 있으면 사진 + 바꾸기 표시
class PetPhotoSlot extends StatelessWidget {
const PetPhotoSlot({super.key, required this.photoPath, required this.onPick});
final String? photoPath;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
final photo = photoPath;
return Semantics(
button: true,
label: photo == null ? '우리 아이 사진 추가하기' : '우리 아이 사진 바꾸기',
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPick,
child: SizedBox(
height: _kSlotHeight,
width: double.infinity,
child: photo == null ? const _EmptySlot() : _FilledSlot(path: photo),
),
),
),
);
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.sand,
borderRadius: BorderRadius.circular(_kSlotRadius),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
AppAssets.addPhoto,
width: 30,
height: 30,
colorFilter: const ColorFilter.mode(AppColors.sandInk, BlendMode.srcIn),
),
const SizedBox(height: 12),
const Text(
'우리 아이 사진 추가하기',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.sandInk,
),
),
],
),
),
);
}
}
class _FilledSlot extends StatelessWidget {
const _FilledSlot({required this.path});
final String path;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_kSlotRadius),
child: PetPhotoImage(path: path),
),
// 사진을 바꿀 수 있다는 표시
Positioned(
right: 10,
bottom: 10,
child: Container(
width: _kEditBadge,
height: _kEditBadge,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .92),
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Color(0x26000000), blurRadius: 6)],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.camera, size: 18, stroke: 2, color: AppColors.ink2),
),
),
],
);
}
}
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
import 'package:tma_app/features/pet/presentation/widgets/species_icon.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
/// 품종 목록 높이 — 시안 320 고정 (검색해도 시트 높이가 흔들리지 않게)
const double _kBreedListHeight = 320;
typedef SpeciesChoice = ({String species, String breed});
/// 종 선택 시트 — 대분류 칩 + 품종 검색 + 품종 목록. 고르면 바로 닫힌다.
Future<SpeciesChoice?> showSpeciesBreedPicker(
BuildContext context, {
required String species,
required String breed,
}) {
return showRupSheet<SpeciesChoice>(
context,
title: '종 선택',
builder: (context) => _SpeciesBreedPicker(species: species, breed: breed),
);
}
class _SpeciesBreedPicker extends StatefulWidget {
const _SpeciesBreedPicker({required this.species, required this.breed});
final String species;
final String breed;
@override
State<_SpeciesBreedPicker> createState() => _SpeciesBreedPickerState();
}
class _SpeciesBreedPickerState extends State<_SpeciesBreedPicker> {
late String _category = widget.species;
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
// 대분류를 바꾸면 검색어를 비운다 (선택된 대분류 안에서만 검색)
void _chooseCategory(String id) => setState(() {
_category = id;
_query.clear();
});
bool _isCurrent(String choice) =>
widget.species == _category &&
widget.breed == breedValueOf(choice) &&
!(choice == kCustomBreed && widget.breed.isNotEmpty);
@override
Widget build(BuildContext context) {
final q = _query.text.trim().toLowerCase();
final choices = categoryOf(_category)
.choices
.where((b) => q.isEmpty || b.toLowerCase().contains(q))
.toList();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_CategoryChips(selected: _category, onSelect: _chooseCategory),
const SizedBox(height: AppSpacing.md),
RupTextField(
controller: _query,
hintText: '$_category 품종 검색',
textInputAction: TextInputAction.search,
onChanged: (_) => setState(() {}),
// 시안: 돋보기 왼쪽 14, 글자 시작 42
prefix: const Padding(
padding: EdgeInsets.only(right: 10),
child: RupIcon(RupIconName.search, size: 18, color: AppColors.muted),
),
suffix: _query.text.isEmpty
? null
: Semantics(
button: true,
label: '지우기',
child: GestureDetector(
onTap: () => setState(_query.clear),
child: Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: AppColors.line2,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 14, stroke: 2.4, color: AppColors.muted),
),
),
),
),
const SizedBox(height: AppSpacing.md),
SizedBox(
height: _kBreedListHeight,
child: choices.isEmpty
? Padding(
padding: const EdgeInsets.all(30),
child: Text(
'검색 결과가 없어요',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
color: AppColors.muted,
),
),
)
: _BleedList(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: choices.length,
itemBuilder: (context, i) => _ChoiceRow(
label: choices[i],
selected: _isCurrent(choices[i]),
onTap: () => Navigator.of(context).pop<SpeciesChoice>((
species: _category,
breed: breedValueOf(choices[i]),
)),
),
),
),
),
],
);
}
}
/// 대분류 칩 — 가로로 넘겨 본다. 시트 좌우 여백을 넘어 화면 끝까지 스크롤된다.
class _CategoryChips extends StatelessWidget {
const _CategoryChips({required this.selected, required this.onSelect});
final String selected;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
height: 1.2,
);
return SizedBox(
height: 42,
child: OverflowBox(
maxWidth: MediaQuery.sizeOf(context).width,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 2),
itemCount: kPetCategories.length,
separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm),
itemBuilder: (context, i) {
final c = kPetCategories[i];
final on = c.id == selected;
final fg = on ? Colors.white : AppColors.ink2;
return Semantics(
button: true,
selected: on,
child: GestureDetector(
onTap: () => onSelect(c.id),
child: AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.fromLTRB(11, 8, 15, 8),
decoration: BoxDecoration(
color: on ? AppColors.primary : AppColors.surface2,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(
color: on ? AppColors.primary : AppColors.line,
width: 1.5,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SpeciesIcon(species: c.id, color: fg),
const SizedBox(width: 7),
Text(c.id, style: style.copyWith(color: fg)),
],
),
),
),
);
},
),
),
);
}
}
/// 선택지 목록은 시트 여백보다 좌우 6 씩 넓게 둔다 (시안 margin: 0 -6px)
class _BleedList extends StatelessWidget {
const _BleedList({required this.child});
static const double _bleed = 6;
final Widget child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => OverflowBox(
minWidth: constraints.maxWidth + _bleed * 2,
maxWidth: constraints.maxWidth + _bleed * 2,
fit: OverflowBoxFit.deferToChild,
child: child,
),
);
}
}
/// 선택지 한 줄 — 고른 항목은 연주황 바탕 + 체크
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({required this.label, required this.selected, required this.onTap});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: selected ? AppColors.primary50 : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppColors.primary : Colors.transparent,
width: 1.5,
),
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (selected)
const RupIcon(RupIconName.check, size: 18, stroke: 2.6, color: AppColors.primary),
],
),
),
),
),
);
}
}
/// 몸무게 단위 선택 시트
Future<WeightUnit?> showWeightUnitPicker(BuildContext context, {required WeightUnit current}) {
return showRupSheet<WeightUnit>(
context,
title: '단위 선택',
builder: (context) => _BleedList(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final unit in WeightUnit.values)
_ChoiceRow(
label: unit.label,
selected: unit == current,
onTap: () => Navigator.of(context).pop(unit),
),
],
),
),
);
}
@@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
/// 종 실루엣 아이콘 — 한 가지 색으로 칠한다 (시안 SpeciesIcon 의 마스크 방식)
class SpeciesIcon extends StatelessWidget {
const SpeciesIcon({super.key, required this.species, required this.color, this.size = 20});
final String species;
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return SvgPicture.asset(
AppAssets.speciesIcon(categoryOf(species).iconKey),
width: size,
height: size,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
excludeFromSemantics: true,
);
}
}
@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
)
@@ -5,10 +5,12 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import flutter_secure_storage_darwin
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}
+116 -4
View File
@@ -97,6 +97,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.15.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
@@ -153,6 +161,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
flutter:
dependency: "direct main"
description: flutter
@@ -174,6 +214,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
@@ -304,6 +352,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
url: "https://pub.dev"
source: hosted
version: "0.8.13+17"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050"
url: "https://pub.dev"
source: hosted
version: "0.8.13+8"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
io:
dependency: transitive
description:
@@ -449,13 +561,13 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
@@ -847,4 +959,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.11.1 <4.0.0"
flutter: ">=3.38.4"
flutter: ">=3.41.0"
+4
View File
@@ -41,6 +41,8 @@ dependencies:
go_router: ^17.5.0
flutter_svg: ^2.3.0
shared_preferences: ^2.5.5
image_picker: ^1.2.3
path_provider: ^2.1.6
dev_dependencies:
flutter_test:
@@ -73,6 +75,8 @@ flutter:
- assets/images/login/
- assets/svg/intro/
- assets/svg/login/
- assets/svg/common/
- assets/svg/species/
# 폰트 — 출처·라이선스는 assets/fonts/README.md
fonts:
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput(
name: name,
species: '강아지',
breed: '말티즈',
gender: PetGender.male,
neutered: true,
birthDate: DateTime(2021, 9, 14),
weight: weight,
weightUnit: WeightUnit.kg,
allergies: const ['닭고기'],
conditions: const ['슬개골 탈구'],
);
void main() {
group('petAgeLabel', () {
test('1살이 안 되면 개월 수만 보여 준다', () {
expect(petAgeLabel(DateTime(2026, 3, 10), DateTime(2026, 9, 28)), '6개월');
});
test('1살 이상이면 "N살 M개월" 로 보여 준다', () {
expect(petAgeLabel(DateTime(2021, 9, 14), DateTime(2026, 9, 28)), '5살 0개월');
});
test('이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다', () {
expect(petAgeLabel(DateTime(2025, 9, 30), DateTime(2026, 9, 28)), '11개월');
});
test('미래 날짜는 0개월로 처리한다', () {
expect(petAgeLabel(DateTime(2027, 1, 1), DateTime(2026, 9, 28)), '0개월');
});
});
group('한국어 조사', () {
test('받침이 있으면 을, 없으면 를', () {
expect(withObjectParticle('콩이'), '콩이를');
expect(withObjectParticle('사랑'), '사랑을');
});
test('한글이 아닌 이름은 를 로 붙인다', () {
expect(withObjectParticle('Max'), 'Max를');
});
test('주격 조사 — 받침이 있으면 이, 없으면 가', () {
expect(withSubjectParticle('초코'), '초코가');
expect(withSubjectParticle('별'), '별이');
});
});
group('날짜 표기', () {
test('시안 형식으로 표기한다', () {
final d = DateTime(2021, 9, 4);
expect(formatYmd(d), '2021.09.04');
expect(formatYmdSpaced(d), '2021 . 09 . 04');
expect(formatMonthDay(d), '9월 4일');
expect(weekdayKo(DateTime(2026, 9, 28)), '월');
});
test('월별 일 수는 윤년을 반영한다', () {
expect(daysInMonth(2024, 2), 29);
expect(daysInMonth(2025, 2), 28);
expect(daysInMonth(2025, 12), 31);
});
test('ISO 날짜 문자열과 서로 바꿀 수 있다', () {
final d = DateTime(2021, 9, 14);
expect(parseIsoDate(toIsoDate(d)), d);
});
});
group('품종 선택값', () {
test('종 모름 항목은 괄호 설명을 뗀 값으로 저장한다', () {
expect(breedValueOf(kUnknownBreed), kUnknownBreedValue);
});
test('직접 입력은 세부 품종을 비워 둔다', () {
expect(breedValueOf(kCustomBreed), '');
});
test('모든 대분류의 선택 목록 맨 위에는 종 모름 항목이 있다', () {
for (final c in kPetCategories) {
expect(c.choices.first, kUnknownBreed);
}
});
});
group('Pet', () {
test('몸무게 표기는 소수점 끝의 0 을 떼어 낸다', () {
expect(Pet.fromInput('a', _input()).weightLabel, '3.2kg');
expect(Pet.fromInput('a', _input(weight: 4)).weightLabel, '4kg');
});
test('JSON 으로 저장했다가 그대로 되살릴 수 있다', () {
final pet = Pet.fromInput('pet-1', _input());
final restored = Pet.fromJson(pet.toJson());
expect(restored.toJson(), pet.toJson());
});
});
group('LocalPetRepository', () {
late LocalPetRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
var tick = 0;
repo = LocalPetRepository(
await SharedPreferences.getInstance(),
clock: () => DateTime.fromMicrosecondsSinceEpoch(++tick),
);
});
test('등록한 순서대로 목록에 쌓인다', () async {
await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
final pets = await repo.fetchPets();
expect(pets.map((p) => p.name), ['콩이', '보리']);
expect(pets.map((p) => p.id).toSet().length, 2);
});
test('수정하면 같은 자리의 값이 바뀐다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.create(_input(name: '보리'));
await repo.update(a.id, _input(name: '콩순이'));
expect((await repo.fetchPets()).map((p) => p.name), ['콩순이', '보리']);
});
test('삭제하면 목록에서 빠진다', () async {
final a = await repo.create(_input(name: '콩이'));
await repo.delete(a.id);
expect(await repo.fetchPets(), isEmpty);
});
});
}
@@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
}
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
)