generated from ttipo/project-base
feat(flutter): 마이페이지·나의 반려동물·반려동물 정보·설정 구현
시안은 아이를 등록하면 "나의 반려동물" 목록으로 보내므로 명함 다음에 만든다. - 마이페이지: 프로필(닉네임·사진 편집), 반려동물·명함 바로가기, 권한·알림 스위치 (스위치는 앱 설정값만 저장한다 — OS 권한·푸시는 해당 기능에서 요청) - 나의 반려동물 / 반려동물 정보(읽기 전용, 연필로 수정) — 아이마다 고유색을 배정한다 - 설정: 약관 문서, 계정 변경·로그아웃, 회원탈퇴(기기 기록 삭제 후 인트로로) - 등록·삭제 후와 홈 "아이 상세보기"는 나의 반려동물 목록으로 간다 시안 대조 중 발견한 공통 문제도 고쳤다. - 줄 높이를 주지 않은 글자가 본문 기본값(1.6)을 물려받아 시안보다 줄이 컸다 → AppTypography.normalHeight 를 두고 섹션 제목·시트 제목에 적용 (홈 간격 보정 포함) - 버튼이 옆 제목 글자와 한 의미 단위로 합쳐져 스크린리더가 함께 읽었다 → Pressable·설정 줄을 별도 의미 노드로 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#clip0_1966_3511)">
|
||||
<path d="M0.679161 19.3289C0.935048 19.7221 1.32316 19.9377 1.81627 20.0725C1.92158 21.6261 2.97302 22.0949 4.44557 22.3781C6.35432 22.7455 8.31631 22.8688 10.2543 22.9556C14.2013 23.1325 18.8438 23.3566 22.6088 21.9251C23.8503 21.4529 24.4387 20.5367 24.667 19.2685C25.0044 17.394 24.9882 15.4233 24.9985 13.5261C25.0089 11.567 24.9688 9.58565 24.7115 7.64157C24.5317 6.28157 24.1552 5.01448 22.7172 4.67622C22.6001 4.24445 22.4343 3.82772 22.1866 3.43836C21.6164 2.54208 20.5125 2.46064 19.5615 2.34306C16.6663 1.98576 13.7243 1.90653 10.8102 1.90625C9.2935 1.90625 7.77709 1.95687 6.2643 2.06564C5.04812 2.1532 3.41968 2.15478 2.32784 2.7554C0.410153 3.81046 0.306091 6.16608 0.15676 8.11395C-0.0917638 11.3552 -0.0485461 14.6983 0.313334 17.937C0.360214 18.3552 0.381745 18.8728 0.679161 19.3289Z" fill="#1F1F1F"></path>
|
||||
<path d="M22.2745 8.92291C22.2374 7.67704 22.2626 6.337 21.9666 5.1174C21.5692 3.48123 20.497 3.29291 19.0292 3.14325C16.3042 2.86552 13.5551 2.75357 10.8163 2.76665C9.30065 2.77389 7.78174 2.80515 6.27036 2.92603C5.03189 3.02523 2.95919 2.94334 2.0857 3.98013C0.670047 5.66064 0.925172 8.82058 0.898935 10.8672C0.867025 13.3484 0.917967 15.8488 1.21172 18.3154C1.45252 19.6416 3.16905 19.4676 4.19924 19.5855C5.17539 19.6971 6.15229 19.8011 7.13126 19.8801C8.92022 20.0247 10.7139 20.1307 12.5088 20.1504C14.3002 20.1697 16.0989 20.1499 17.8837 19.9833C19.0034 19.8788 20.289 19.8163 21.3446 19.3796C22.2613 19.0006 22.1451 18.0296 22.1775 17.1926C22.2177 16.1429 22.2539 15.093 22.274 14.0429C22.3066 12.3371 22.3257 10.6287 22.2745 8.92291ZM9.69299 15.4591C9.64551 16.0194 9.5674 16.326 9.23731 16.5846C8.76324 16.9558 7.60336 16.8088 7.05207 16.8188C5.95363 16.8393 4.83286 16.8628 3.73853 16.744C2.90419 16.6536 2.68677 16.2647 2.62862 15.3989C2.55679 14.3253 2.33499 12.5713 3.05682 11.6591C3.5059 11.0916 4.37674 11.2232 5.03157 11.2102C6.12811 11.1882 7.24084 11.1641 8.33537 11.2556C8.82377 11.2965 9.12855 11.4994 9.32504 11.8163C9.63062 12.3097 9.65827 13.0362 9.7144 13.6344C9.76169 14.1369 9.73854 14.922 9.69299 15.4591ZM20.3082 9.33256C19.9984 9.89279 19.5504 10.0052 18.9578 10.0421C17.3103 10.1448 15.6537 10.1448 14.0035 10.1564C10.8049 10.1795 7.58332 10.2358 4.388 10.0537C3.95433 10.0291 3.55382 10.0217 3.17907 9.77629C2.61474 9.40721 2.60186 8.40937 2.59623 7.82605C2.58411 6.56476 2.5607 4.67331 4.16294 4.60856C7.30168 4.482 10.4535 4.52257 13.5941 4.535C15.1317 4.54112 16.67 4.54679 18.2071 4.58438C18.6026 4.59428 18.9996 4.59898 19.3939 4.63294C20.1107 4.69471 20.3093 5.5357 20.4356 6.10534C20.5867 7.03243 20.7905 8.46096 20.3082 9.33256Z" fill="#FF7500"></path>
|
||||
<path d="M3.19731 12.6096C3.35086 11.5621 4.22737 11.7365 5.02997 11.7162C5.84137 11.6956 6.6539 11.7009 7.46531 11.7212C7.755 11.7288 8.0444 11.742 8.33377 11.7616C8.941 11.8028 9.05322 12.3604 9.13133 12.885C9.2038 13.8517 9.24859 14.8537 9.09564 15.8147C8.99299 16.4609 7.75387 16.2863 7.26801 16.3009C6.51918 16.3237 5.76954 16.3193 5.02067 16.3009C4.47381 16.2877 3.27714 16.4763 3.19304 15.8147C3.04214 14.6256 3.05723 13.5635 3.19731 12.6096Z" fill="#FBB800"></path>
|
||||
<path d="M23.0266 17.1878C23.1791 13.5268 23.31 9.80357 22.9687 6.14912C22.9356 5.79182 23.0039 5.62519 23.333 6.04413C23.9178 6.7895 23.9329 8.06825 24.0085 8.98058C24.1437 10.6174 24.1626 12.2657 24.1538 13.9072C24.1453 15.4775 24.0877 17.0525 23.9148 18.6139C23.7347 20.2406 23.0726 20.9524 21.4999 21.407C18.4141 22.2995 14.9574 22.2149 11.778 22.1602C10.0778 22.1314 8.37799 22.0317 6.68694 21.8539C5.50504 21.7299 3.43976 21.7118 2.83362 20.586C2.68932 20.3178 2.76617 20.2499 3.14644 20.2993C4.93335 20.5311 6.7275 20.7037 8.52527 20.829C12.0796 21.0767 15.6978 21.1327 19.2378 20.6786C20.2155 20.5535 21.2634 20.4126 22.1362 19.9288C23.0653 19.4137 22.9894 18.0799 23.0266 17.1878Z" fill="#FFC522"></path>
|
||||
<path d="M3.11465 6.94773C3.13477 6.51877 3.2206 5.17121 3.85471 5.12481C4.0537 5.1102 4.25349 5.10501 4.45264 5.09809C5.86121 5.05077 7.27199 5.04763 8.68132 5.03946C11.9763 5.02015 15.2763 5.00063 18.5707 5.09463C19.3879 5.11789 19.7274 5.28339 19.9373 6.23174C20.0531 6.94129 20.3997 9.37615 19.2667 9.4986C18.8069 9.54826 18.3412 9.55647 17.8794 9.57216C16.2958 9.62544 14.7106 9.6355 13.1265 9.64351C10.0999 9.6586 7.03337 9.74 4.01253 9.51164C2.87856 9.42597 3.07803 7.72787 3.11465 6.94773Z" fill="white"></path>
|
||||
<path d="M5.02016 16.298C5.76899 16.3164 6.51863 16.3208 7.2675 16.298C7.75336 16.2834 8.99247 16.458 9.09513 15.8118C9.19291 15.1972 9.20941 14.5656 9.19005 13.9375C9.11995 14.4073 8.96289 14.8531 8.6553 15.2189C8.10968 15.8681 6.90594 15.6976 6.14894 15.7481C5.37383 15.8002 4.5582 15.8569 3.8587 15.4588C3.38463 15.1892 3.18907 14.6848 3.08597 14.1436C3.08472 14.6705 3.11805 15.2257 3.19253 15.8118C3.27663 16.4734 4.47329 16.2848 5.02016 16.298Z" fill="#BF8A19"></path>
|
||||
<path d="M7.47344 12.2617C6.56398 12.2219 4.43108 12.188 4.15065 12.4469C3.90072 12.6776 3.91251 13.2232 3.95037 13.5047C3.9851 13.7629 4.22734 13.8454 4.48858 13.8883C4.90688 13.957 6.72111 13.9615 7.63738 13.8752C8.20751 13.8218 8.43921 13.7776 8.46311 13.3859C8.47225 13.239 8.4213 12.9273 8.34851 12.7064C8.22606 12.3338 7.90868 12.2801 7.47344 12.2617Z" fill="#FFEE6C"></path>
|
||||
<path d="M9.59817 16.0299C9.75254 15.7524 9.8335 15.4155 9.86666 15.1189C9.97277 14.1687 9.85205 13.0624 9.63528 12.1284C9.44836 11.3231 8.75896 11.1071 8.02659 11.062C6.93745 10.9949 5.83853 11.0082 4.74907 11.0538C4.08937 11.0814 3.47474 11.0628 3.01906 11.6082C2.81958 11.847 2.70639 12.1796 2.65625 12.5422C2.735 12.2047 2.85935 11.8979 3.0533 11.6526C3.50238 11.0851 4.37322 11.2167 5.02805 11.2036C6.1246 11.1816 7.23732 11.1576 8.33186 11.2491C8.82025 11.29 9.12503 11.4929 9.32152 11.8098C9.62711 12.3032 9.65475 13.0297 9.71088 13.6278C9.75821 14.1304 9.73511 14.9155 9.68952 15.4526C9.66984 15.6841 9.64377 15.8705 9.59817 16.0299Z" fill="#F0420D"></path>
|
||||
<path d="M4.96813 4.38259C4.43056 4.41341 3.71533 4.34078 3.2609 4.70229C2.75303 5.10627 2.63625 5.63096 2.56941 6.2569C2.5169 6.74747 2.46974 7.45247 2.59722 8.06992C2.59484 7.98063 2.59376 7.89512 2.59299 7.81794C2.58088 6.55665 2.55746 4.66519 4.15971 4.60045C7.29845 4.47389 10.4502 4.51445 13.5908 4.52689C15.1285 4.533 16.6667 4.53868 18.2039 4.57626C18.5993 4.58616 18.9964 4.59087 19.3906 4.62483C20.1074 4.6866 20.3061 5.52758 20.4323 6.09722C20.5763 6.98001 20.7665 8.31639 20.368 9.19464C20.5381 8.97456 20.6423 8.71067 20.7058 8.32633C20.8448 7.48145 20.7568 6.59568 20.57 5.76572C20.4652 5.30107 20.256 4.81396 19.8267 4.57107C19.4304 4.34693 18.8532 4.41076 18.4156 4.39752C15.9991 4.32315 13.5789 4.32347 11.1617 4.31876C9.10185 4.3147 7.0257 4.2644 4.96813 4.38259Z" fill="#F0420D"></path>
|
||||
<path d="M22.3006 12.1161C22.242 13.083 22.1664 14.0487 22.0647 15.0122C21.9797 15.8206 21.8652 16.627 21.7374 17.4297C21.6382 18.0509 21.4322 18.5741 20.8573 18.89C19.8851 19.4247 18.5682 19.443 17.4968 19.5411C15.9005 19.6871 14.3015 19.7643 12.6988 19.7701C10.8553 19.7764 9.01177 19.6992 7.17283 19.5788C6.16055 19.5125 5.14951 19.4268 4.13956 19.3306C3.51097 19.2709 2.75176 19.3026 2.18916 18.9618C1.75279 18.6973 1.62438 18.1262 1.5326 17.6639C1.36379 16.8116 1.3281 15.9257 1.26049 15.0615C1.15329 13.6914 1.04798 12.3217 0.989957 10.9483C0.929919 9.53253 0.920181 8.10887 1.04215 6.69531C0.837165 8.12292 0.914026 9.67921 0.898935 10.8635C0.867025 13.3447 0.917967 15.8451 1.21172 18.3117C1.45252 19.6379 3.16905 19.4639 4.19924 19.5818C5.17539 19.6934 6.15229 19.7975 7.13126 19.8764C8.92022 20.021 10.7139 20.1271 12.5088 20.1467C14.3002 20.1661 16.0989 20.1463 17.8837 19.9797C19.0034 19.8751 20.289 19.8126 21.3446 19.3759C22.2613 18.9969 22.1451 18.026 22.1775 17.1889C22.2177 16.1392 22.2539 15.0893 22.274 14.0392C22.2862 13.3983 22.295 12.7573 22.3006 12.1161Z" fill="#F0420D"></path>
|
||||
<path d="M22.2685 8.68244C22.2399 7.50541 22.2439 6.25905 21.9667 5.1174C21.5694 3.48123 20.4972 3.29291 19.0293 3.14325C16.3043 2.86552 13.5553 2.75357 10.8164 2.76665C9.30079 2.77389 7.78188 2.80515 6.2705 2.92603C5.03202 3.02523 2.95933 2.94334 2.08583 3.98013C1.88322 4.22077 1.71784 4.49428 1.57813 4.78791C2.21428 4.13022 3.01909 3.70883 4.00179 3.51752C4.76777 3.36851 5.56867 3.33504 6.34531 3.28096C9.31616 3.0738 12.3037 3.05726 15.2785 3.18225C16.8082 3.24671 18.4518 3.24196 19.9438 3.62018C21.1816 3.93377 21.5887 4.90111 21.8328 6.02892C22.0219 6.90225 22.1604 7.78943 22.2685 8.68244Z" fill="#FFAE52"></path>
|
||||
<path d="M23.2446 6.56512C23.2289 6.33108 23.2092 6.10143 23.1764 5.87681C22.9838 5.70325 22.9409 5.86172 22.9679 6.15205C23.3092 9.80651 23.1783 13.5297 23.0258 17.1908C22.9885 18.0828 23.0645 19.4167 22.1355 19.9316C21.2626 20.4154 20.2148 20.5563 19.2371 20.6814C15.6971 21.1355 12.0789 21.0796 8.52455 20.8318C6.72678 20.7066 4.93263 20.534 3.14572 20.3021C2.85712 20.2647 2.74412 20.2952 2.76895 20.4291C5.14989 20.9857 7.66144 21.1266 10.0831 21.2311C13.1769 21.3644 16.3015 21.3836 19.3852 21.0672C20.3634 20.9669 21.6605 20.9337 22.4757 20.301C23.1873 19.7488 23.2891 18.8299 23.3457 17.9883C23.5224 15.3523 23.435 12.6768 23.3786 10.0381C23.3535 8.88006 23.3214 7.7211 23.2446 6.56512Z" fill="#FFA222"></path>
|
||||
<path d="M4.01187 9.51429C7.03271 9.7427 10.0993 9.66125 13.1258 9.64616C14.7099 9.63815 16.2952 9.62809 17.8787 9.57482C18.3405 9.55908 18.8063 9.55091 19.2661 9.50126C19.8025 9.44327 20.0053 8.86574 20.0599 8.19973C19.296 8.85725 18.2236 9.08831 17.0686 9.1496C15.2298 9.24658 13.3753 9.2543 11.5343 9.24311C9.61629 9.23181 7.66528 9.34983 5.75339 9.20179C4.80038 9.12839 3.66717 8.99037 3.18256 8.05196C3.1414 7.97241 3.1082 7.88911 3.07818 7.80469C3.07504 8.58406 3.20848 9.45361 4.01187 9.51429Z" fill="#C7C7C7"></path>
|
||||
<path d="M17.9213 5.5788C16.0154 5.46593 14.082 5.49896 12.1764 5.48826C10.0582 5.47615 7.93843 5.46625 5.8203 5.49707C5.41476 5.50319 4.96158 5.4974 4.56799 5.58274C4.19842 5.66339 3.93669 5.94929 3.83879 6.24985C3.64246 6.85329 4.0441 7.14314 4.75096 7.16282C7.28766 7.23324 15.2573 7.19127 18.0147 7.15779C18.8814 7.14773 19.3624 7.03614 19.1332 6.32124C19.0194 5.96547 18.6414 5.62093 17.9213 5.5788Z" fill="#EEEEEE"></path>
|
||||
<path d="M20.2934 14.3063C17.227 14.3836 14.1573 14.3721 11.09 14.3721C10.7617 14.3721 10.7617 13.8628 11.09 13.8628C14.1573 13.8628 17.227 13.8743 20.2934 13.797C20.6219 13.7888 20.6211 14.2981 20.2934 14.3063Z" fill="#1F1F1F"></path>
|
||||
<path d="M20.2988 18.3611C14.5289 18.6085 8.60838 18.9349 2.84602 18.4268C2.52157 18.3982 2.51859 17.8886 2.84602 17.9176C8.60838 18.4256 14.5289 18.0993 20.2988 17.8519C20.6272 17.8377 20.6258 18.347 20.2988 18.3611Z" fill="#1F1F1F"></path>
|
||||
<path d="M20.2934 12.2438C17.227 12.3209 14.1573 12.3095 11.09 12.3095C10.7617 12.3095 10.7617 11.8002 11.09 11.8002C14.1573 11.8002 17.227 11.8117 20.2934 11.7345C20.6219 11.7261 20.6211 12.2354 20.2934 12.2438Z" fill="#1F1F1F"></path>
|
||||
<path d="M20.3012 16.3844C17.2348 16.4616 14.1651 16.4502 11.0978 16.4502C10.7695 16.4502 10.7695 15.941 11.0978 15.941C14.1651 15.941 17.2348 15.9523 20.3012 15.8751C20.6297 15.8669 20.6289 16.3762 20.3012 16.3844Z" fill="#1F1F1F"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1966_3511">
|
||||
<rect width="25" height="25" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs></svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.4621 20.8281C11.2178 20.2689 11.1472 19.6746 11.1199 19.0686C11.0921 18.453 11.2366 17.8587 11.2794 17.2482C11.2956 17.0155 11.3396 16.6818 11.2074 16.4718C11.0765 16.2644 10.8413 16.2683 10.6235 16.2534C9.96702 16.208 9.31117 16.1633 8.65468 16.118C8.48099 16.1063 8.35656 16.2301 8.31703 16.3791C8.13946 16.4562 8.01049 16.663 8.13039 16.8833C8.24833 17.1004 8.20297 17.3972 8.19973 17.6357C8.19649 17.9209 8.18612 18.206 8.17381 18.4912C8.16149 18.766 8.16409 19.0485 8.13103 19.3214C8.10446 19.5404 7.96513 19.74 8.09085 19.9539C8.20297 20.1438 8.46285 20.2241 8.64949 20.3168C8.90742 20.4451 9.16795 20.5683 9.43107 20.6869C9.96637 20.9279 10.5101 21.1483 11.061 21.3505C11.3869 21.4704 11.5742 21.0802 11.4634 20.8268L11.4621 20.8281Z" fill="white"></path>
|
||||
<path d="M20.1875 13.2088C19.8349 12.6975 19.3851 12.2432 18.8842 11.879C18.6418 11.7027 18.3949 11.4966 18.0896 11.459C17.7714 11.4227 17.4416 11.5575 17.2037 11.7733C16.5103 12.4681 15.6561 12.9781 14.8344 13.5115C14.7125 13.586 14.5881 13.6463 14.4507 13.6806C14.1967 13.7486 13.9135 13.7104 13.6581 13.807C13.4955 13.8666 13.3464 13.9619 13.1993 14.0558C12.7787 14.3514 12.2849 14.5134 11.8403 14.7681C11.281 15.0804 11.1391 15.619 11.1332 16.223C11.0937 17.4206 11.0412 18.6001 10.8643 19.7854C10.8403 19.9533 10.8163 20.1263 10.8209 20.2942C10.8222 21.1587 11.7541 21.3266 12.3905 21.3499C13.0943 21.3661 13.8649 21.379 14.5389 21.0958C15.1552 20.8437 15.7203 20.4815 16.2997 20.1561C16.498 20.0395 16.7196 19.8716 16.9562 19.9637C17.1007 20.011 17.2575 20.1367 17.4429 20.1678C17.811 20.2404 18.183 19.9799 18.2607 19.6143C18.334 19.3331 18.1134 19.0511 18.1711 18.7692C18.2035 18.5974 18.2795 18.3558 18.3554 18.1977C18.7261 17.525 19.133 16.8438 19.1855 16.0746C19.2141 15.7596 19.1888 15.4103 19.3845 15.1472C19.6074 14.8718 19.853 14.6223 20.0637 14.3293C20.3521 13.9833 20.4609 13.6281 20.1913 13.2179L20.1855 13.2082L20.1875 13.2088ZM16.6192 19.6396L16.6244 19.6455C16.6244 19.6455 16.6205 19.6422 16.6192 19.6396Z" fill="#FCF6EB"></path>
|
||||
<path d="M23.7358 9.8355C24.5524 19.3168 13.8648 22.4301 12.4865 22.4301C10.9859 22.4301 0.72469 19.7173 1.23732 9.8355C1.43238 6.08705 4.28325 2.71124 7.17106 2.49025C9.4108 2.31851 11.3868 2.87715 12.4872 4.81423C13.587 2.87715 15.5597 2.371 17.8027 2.49025C20.7184 2.64578 23.4137 6.09612 23.7358 9.8355Z" stroke="#FF7500" stroke-width="2.28802" stroke-miterlimit="10"></path>
|
||||
<path d="M20.1504 11.5451C19.8212 10.8828 19.6352 10.886 20.0506 10.1667C20.1718 9.92819 20.2463 9.6573 20.328 9.40585C20.4096 9.1855 20.374 8.92887 20.1018 8.8848C19.7577 8.84138 19.3786 8.9334 19.1051 9.15569C18.8582 9.32354 18.6125 9.49723 18.3857 9.69424C18.2567 9.78821 18.0993 9.79339 17.9444 9.77071C17.2574 9.67739 16.5445 9.70979 15.8809 9.85431C15.4927 9.95671 15.4746 9.72016 15.2821 9.42205C15.0261 9.04552 14.5848 8.75518 14.1259 8.74222C13.4131 8.73445 13.402 10.0345 13.4714 10.5354C13.5362 10.9314 13.3256 11.2716 13.1979 11.6313C13.1428 11.8121 13.1123 11.9988 13.0864 12.1874C13.0281 12.595 13.0326 13.0111 13.1111 13.4168C13.1273 13.4991 13.1441 13.5814 13.1454 13.6637C13.148 13.8166 13.102 13.9676 13.1823 14.035C13.2471 14.0966 13.4059 14.0934 13.5057 14.0746C13.6839 14.0447 13.8628 14.0072 14.0391 13.9663C15.5614 13.6812 16.4959 12.6566 17.6968 11.8083C17.9781 11.5989 18.1265 11.6922 18.3138 11.9456C18.5374 12.2211 18.7674 12.4959 19.0169 12.7493C19.2172 12.9476 20.1932 13.9022 20.4083 13.4991C20.6015 12.9689 20.3896 12.0571 20.1543 11.5555L20.1498 11.5451H20.1504Z" fill="#D3A061"></path>
|
||||
<path d="M17.4325 13.6079C17.4668 13.6863 17.5096 13.7602 17.5601 13.8289C17.5835 13.8607 17.6068 13.8911 17.6288 13.9242C17.6405 13.941 17.6509 13.9592 17.6606 13.9773C17.6625 13.9818 17.6651 13.9857 17.6671 13.9903C17.6684 13.9929 17.6742 14.0078 17.6697 13.9948C17.6651 13.9818 17.671 13.9974 17.6716 14C17.6736 14.0058 17.6755 14.0117 17.6774 14.0168C17.693 14.0648 17.7455 14.1017 17.7973 14.0849C17.8453 14.0693 17.8822 14.0168 17.8654 13.965C17.8375 13.8788 17.7863 13.8088 17.7332 13.7362C17.68 13.6636 17.636 13.5898 17.601 13.5088C17.5809 13.4621 17.5083 13.4478 17.4681 13.4738C17.4195 13.5049 17.4117 13.5567 17.4331 13.6066L17.4325 13.6079Z" fill="#1F1F1F"></path>
|
||||
<path d="M17.842 13.0267C17.737 13.0098 17.6158 13.004 17.5063 13.0001C17.2464 12.9923 17.5607 13.3455 17.6839 13.2956C17.7791 13.273 18.0572 13.0896 17.8491 13.028L17.842 13.0267Z" fill="#1F1F1F"></path>
|
||||
<path d="M16.9245 12.3262C16.9283 12.0954 16.7958 11.9061 16.6287 11.9034C16.4616 11.9007 16.323 12.0856 16.3193 12.3164C16.3155 12.5473 16.448 12.7366 16.6151 12.7393C16.7822 12.742 16.9208 12.5571 16.9245 12.3262Z" fill="#1F1F1F"></path>
|
||||
<mask id="mask0_1966_3476" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="16" y="11" width="1" height="2">
|
||||
<path d="M16.9558 12.3184C16.9595 12.0876 16.8271 11.8983 16.6599 11.8956C16.4928 11.8929 16.3543 12.0778 16.3505 12.3086C16.3468 12.5394 16.4792 12.7288 16.6464 12.7315C16.8135 12.7342 16.952 12.5493 16.9558 12.3184Z" fill="white"></path>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1966_3476)">
|
||||
<path d="M16.7177 12.1932C16.73 12.2004 16.743 12.2062 16.7566 12.2094C16.7702 12.2127 16.7858 12.2153 16.8007 12.2153C16.8013 12.2153 16.8434 12.2094 16.8441 12.2094C16.8577 12.2062 16.8713 12.1997 16.8836 12.1926C16.9115 12.1777 16.9251 12.1582 16.9432 12.1336C16.9432 12.1336 16.9432 12.1336 16.9432 12.133C16.9504 12.1207 16.9549 12.107 16.9601 12.0934C16.9601 12.0934 16.9601 12.0928 16.9601 12.0921C16.9627 12.0779 16.9692 12.0144 16.9659 12.0487C16.9659 12.0487 16.9659 12.0481 16.9659 12.0474C16.9659 12.0325 16.964 12.0176 16.9601 12.0034C16.9523 11.9768 16.9381 11.9496 16.9173 11.9308C16.8953 11.91 16.8739 11.8964 16.8447 11.8886C16.8156 11.8809 16.7838 11.8809 16.7547 11.8886C16.7417 11.8938 16.7287 11.8997 16.7158 11.9048C16.7158 11.9048 16.7158 11.9048 16.7151 11.9048C16.7035 11.9126 16.6924 11.9217 16.6814 11.9308C16.6814 11.9308 16.6814 11.9308 16.6808 11.9314C16.6717 11.9424 16.662 11.9528 16.6549 11.9651C16.6549 11.9651 16.6549 11.9651 16.6549 11.9658C16.6698 11.934 16.6432 11.9917 16.6387 12.0047C16.6387 12.0047 16.6387 12.0053 16.6387 12.0059C16.6315 12.0513 16.6302 12.0487 16.6387 12.0941C16.6387 12.0941 16.6387 12.0941 16.6387 12.0947C16.6432 12.1083 16.6477 12.122 16.6555 12.1343C16.673 12.1621 16.6866 12.1744 16.7145 12.1926C16.7261 12.2004 16.7398 12.2049 16.7534 12.2088C16.7534 12.2088 16.7534 12.2088 16.754 12.2088C16.7689 12.2114 16.7832 12.2133 16.7981 12.2146H16.7994C16.8279 12.2127 16.8584 12.2088 16.883 12.1919L16.916 12.1667L16.9413 12.1336C16.9478 12.1213 16.953 12.1077 16.9582 12.0941C16.9646 12.0727 16.9659 12.0513 16.9607 12.0293C16.9595 12.0079 16.9536 11.9878 16.9413 11.969C16.9309 11.9502 16.9173 11.9347 16.8998 11.9236C16.8843 11.9094 16.8661 11.8997 16.8447 11.8945L16.802 11.8886C16.7728 11.8886 16.7456 11.8964 16.7203 11.9107L16.6873 11.936C16.6672 11.956 16.6536 11.98 16.6458 12.0072C16.651 11.9943 16.6568 11.9813 16.662 11.969C16.662 11.969 16.662 11.969 16.662 11.9697L16.6873 11.9366L16.7203 11.9113C16.7203 11.9113 16.7203 11.9113 16.7197 11.9113L16.7585 11.8951C16.7585 11.8951 16.7585 11.8951 16.7579 11.8951L16.8007 11.8893C16.8007 11.8893 16.7994 11.8893 16.7987 11.8893L16.8415 11.8951H16.8402L16.8791 11.9113L16.9121 11.9366L16.9374 11.9697L16.9536 12.0085C16.9536 12.0085 16.9536 12.0079 16.9536 12.0072L16.9595 12.05C16.9595 12.05 16.9595 12.0487 16.9595 12.0481L16.9536 12.0908C16.9536 12.0908 16.9536 12.0895 16.9536 12.0889L16.9374 12.1278C16.9374 12.1278 16.9374 12.1265 16.9381 12.1265L16.9128 12.1595C16.9128 12.1595 16.9134 12.1589 16.9141 12.1582L16.881 12.1835C16.881 12.1835 16.8817 12.1835 16.8823 12.1829L16.8434 12.1991C16.8434 12.1991 16.8447 12.1991 16.8454 12.1991L16.8026 12.2049C16.8026 12.2049 16.8039 12.2049 16.8046 12.2049L16.7618 12.1991C16.7618 12.1991 16.7624 12.1991 16.7631 12.1991L16.7242 12.1829C16.7242 12.1829 16.7242 12.1829 16.7248 12.1829L16.6918 12.1576L16.6665 12.1245L16.6503 12.0857C16.6503 12.0857 16.6503 12.0863 16.6503 12.087L16.6445 12.0442V12.0455L16.6503 12.0027C16.6503 12.0027 16.6503 12.0047 16.6503 12.0053L16.6665 11.9664C16.6665 11.9664 16.6665 11.9671 16.6659 11.9677L16.6911 11.9347C16.6911 11.9347 16.6911 11.9347 16.6905 11.9353L16.7236 11.91C16.7236 11.91 16.7236 11.91 16.7229 11.91L16.7618 11.8938C16.7618 11.8938 16.7618 11.8938 16.7611 11.8938L16.8039 11.888C16.8039 11.888 16.8039 11.888 16.8033 11.888L16.846 11.8938C16.846 11.8938 16.846 11.8938 16.8454 11.8938L16.8843 11.91C16.8843 11.91 16.8843 11.91 16.8836 11.91C16.8648 11.8984 16.8447 11.8919 16.8234 11.8906C16.802 11.8861 16.7806 11.8867 16.7585 11.8932C16.7372 11.8984 16.719 11.9081 16.7035 11.9223C16.686 11.934 16.6717 11.9489 16.662 11.9677L16.6458 12.0066C16.638 12.0351 16.638 12.0643 16.6458 12.0928L16.662 12.1317C16.6762 12.1563 16.6957 12.1751 16.7203 12.19L16.7177 12.1932Z" fill="white"></path>
|
||||
</g>
|
||||
<path d="M19.0048 12.3884C19.034 12.1594 18.9232 11.9566 18.7574 11.9355C18.5916 11.9144 18.4336 12.0829 18.4044 12.3119C18.3752 12.5409 18.486 12.7437 18.6518 12.7648C18.8176 12.7859 18.9757 12.6174 19.0048 12.3884Z" fill="#1F1F1F"></path>
|
||||
<mask id="mask1_1966_3476" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="18" y="11" width="1" height="2">
|
||||
<path d="M18.9814 12.3806C19.0106 12.1516 18.8998 11.9488 18.734 11.9277C18.5682 11.9066 18.4101 12.0751 18.3809 12.3041C18.3518 12.5331 18.4625 12.7359 18.6283 12.757C18.7942 12.7781 18.9522 12.6096 18.9814 12.3806Z" fill="white"></path>
|
||||
</mask>
|
||||
<g mask="url(#mask1_1966_3476)">
|
||||
<path d="M18.7615 12.2292C18.7816 12.2486 18.8069 12.2655 18.8347 12.2713C18.8626 12.2771 18.8943 12.2804 18.9222 12.2713C18.9533 12.2616 18.9715 12.2512 18.9954 12.2285C19.0045 12.2175 19.0129 12.2065 19.0207 12.1955C19.0207 12.1955 19.0207 12.1948 19.0214 12.1942C19.0265 12.1812 19.0324 12.1682 19.0376 12.1553C19.0376 12.1553 19.0376 12.1546 19.0376 12.154C19.0473 12.1177 19.044 12.1015 19.0376 12.0659C19.0324 12.0373 19.0129 12.014 18.9954 11.9926C18.9844 11.9835 18.9734 11.9751 18.9624 11.9673C18.9624 11.9673 18.9624 11.9673 18.9617 11.9673L18.9229 11.9511C18.9209 11.9511 18.882 11.9453 18.8794 11.9453C18.8794 11.9453 18.8788 11.9453 18.8781 11.9453C18.8639 11.9473 18.849 11.9492 18.8347 11.9511C18.8347 11.9511 18.8341 11.9511 18.8334 11.9511C18.8205 11.957 18.8075 11.9622 18.7945 11.9673C18.7945 11.9673 18.7939 11.9673 18.7932 11.9673C18.7816 11.9751 18.7699 11.9835 18.7595 11.9933C18.7595 11.9933 18.7595 11.9933 18.7595 11.9939C18.7505 12.0049 18.7414 12.0153 18.7336 12.0276C18.7336 12.0276 18.7336 12.0276 18.7336 12.0283C18.7278 12.0406 18.7207 12.0542 18.7168 12.0678C18.7168 12.0678 18.7168 12.0678 18.7168 12.0684C18.7252 12.016 18.7122 12.0976 18.7109 12.1119C18.7109 12.1132 18.7161 12.1533 18.7168 12.1553C18.7207 12.1689 18.7265 12.1825 18.7336 12.1948C18.7472 12.2182 18.7686 12.2402 18.7926 12.2538C18.8049 12.2609 18.8185 12.2661 18.8321 12.2706C18.8321 12.2706 18.8328 12.2706 18.8334 12.2706C18.8477 12.2739 18.8626 12.2765 18.8775 12.2765C18.8775 12.2765 18.8775 12.2765 18.8781 12.2765L18.9209 12.2706C18.9345 12.2674 18.9481 12.2609 18.9604 12.2538C18.9728 12.2467 18.9838 12.2376 18.9941 12.2279C19.0032 12.2169 19.0116 12.2058 19.0194 12.1948C19.0194 12.1948 19.0194 12.1948 19.0194 12.1942C19.0311 12.1754 19.0376 12.1553 19.0389 12.1339C19.0434 12.1125 19.0427 12.0911 19.0363 12.0691C19.0311 12.0477 19.0214 12.0296 19.0071 12.014C18.9954 11.9965 18.9805 11.9823 18.9617 11.9725L18.9229 11.9563C18.8943 11.9486 18.8652 11.9486 18.8367 11.9563L18.7978 11.9725C18.7732 11.9868 18.7544 12.0062 18.7395 12.0309C18.7395 12.0309 18.7395 12.0309 18.7395 12.0315L18.7647 11.9985L18.7978 11.9732C18.7978 11.9732 18.7978 11.9732 18.7971 11.9732L18.836 11.957C18.836 11.957 18.836 11.957 18.8354 11.957L18.8781 11.9511C18.8781 11.9511 18.8775 11.9511 18.8768 11.9511L18.9196 11.957C18.9196 11.957 18.9177 11.957 18.917 11.957L18.9559 11.9732L18.989 11.9985L19.0142 12.0315L19.0304 12.0704C19.0304 12.0704 19.0304 12.0697 19.0304 12.0691L19.0363 12.1119V12.1106L19.0304 12.1533C19.0304 12.1533 19.0304 12.152 19.0304 12.1514L19.0142 12.1903C19.0142 12.1903 19.0142 12.189 19.0149 12.189L18.9896 12.222C18.9896 12.222 18.9903 12.2214 18.9909 12.2207L18.9579 12.246C18.9579 12.246 18.9585 12.246 18.9591 12.2454L18.9203 12.2616C18.9203 12.2616 18.9216 12.2616 18.9222 12.2616L18.8794 12.2674C18.8794 12.2674 18.8807 12.2674 18.8814 12.2674L18.8386 12.2616C18.8386 12.2616 18.8393 12.2616 18.8399 12.2616L18.801 12.2454C18.801 12.2454 18.801 12.2454 18.8017 12.2454L18.7686 12.2201L18.7433 12.187L18.7271 12.1482C18.7271 12.1482 18.7271 12.1482 18.7271 12.1488L18.7213 12.106C18.7213 12.106 18.7213 12.1067 18.7213 12.1073L18.7271 12.0646C18.7271 12.0646 18.7271 12.0659 18.7271 12.0665L18.7433 12.0276C18.7433 12.0276 18.7433 12.0289 18.742 12.0296L18.7673 11.9965C18.7673 11.9965 18.7673 11.9972 18.7667 11.9972L18.7997 11.9719C18.7997 11.9719 18.7997 11.9719 18.7991 11.9719L18.838 11.9557C18.838 11.9557 18.838 11.9557 18.8373 11.9557L18.8801 11.9498C18.8801 11.9498 18.8801 11.9498 18.8794 11.9498L18.9222 11.9557C18.9222 11.9557 18.9222 11.9557 18.9216 11.9557L18.9604 11.9719L18.9935 11.9972C18.9786 11.9816 18.9617 11.9699 18.9417 11.9635C18.9222 11.9531 18.9015 11.9486 18.8794 11.9492C18.8574 11.9486 18.836 11.9531 18.8172 11.9635C18.7971 11.9699 18.7796 11.981 18.7654 11.9972L18.7401 12.0302C18.7252 12.0555 18.7181 12.0827 18.7181 12.1119L18.7239 12.1546C18.7317 12.1819 18.7453 12.2058 18.7654 12.2259L18.7615 12.2292Z" fill="white"></path>
|
||||
</g>
|
||||
<path d="M17.534 13.1968C17.4206 13.4716 17.3195 13.7515 17.2314 14.0354C17.1944 14.1553 17.3817 14.2065 17.4187 14.0872C17.5068 13.8034 17.6079 13.5234 17.7213 13.2486C17.7414 13.2 17.6999 13.1417 17.6533 13.1287C17.5975 13.1132 17.5541 13.1482 17.5334 13.1968H17.534Z" fill="#1F1F1F"></path>
|
||||
<path d="M18.1609 19.3184C18.1428 19.1855 18.1058 19.0598 18.0456 18.9392C18.0157 18.879 17.9801 18.8116 17.9205 18.7766C17.831 18.7234 17.7442 18.761 17.6574 18.7941C17.509 18.8504 17.3612 18.9055 17.2134 18.9626C17.067 19.0189 16.9212 19.0786 16.7889 19.1641C16.6956 19.2244 16.6878 19.3365 16.7559 19.4188C16.776 19.4428 16.8006 19.459 16.8265 19.4713C16.9296 19.7137 17.0436 19.9522 17.2692 20.1045C17.4876 20.2516 17.7909 20.2548 17.9905 20.0727C18.0903 19.982 18.135 19.8491 18.1544 19.7189C18.1745 19.5847 18.1778 19.4532 18.1596 19.3184H18.1609Z" fill="#FCF6EB"></path>
|
||||
<path d="M11.2188 20.6864L11.2298 15.2439C11.8027 14.7306 12.4689 14.3456 13.2323 14.0935C12.7139 13.1046 12.872 12.0333 13.3937 10.918C13.3937 10.918 13.0839 8.79621 14.0275 8.63095C14.9711 8.46569 15.5978 9.84349 15.5978 9.84349C16.5757 9.63157 17.4694 9.60241 18.2698 9.7748C18.2698 9.7748 19.7403 8.46569 20.295 8.79685C20.8497 9.12737 19.8135 10.9186 19.8135 10.9186C20.8504 12.5725 20.6599 13.9853 19.3034 15.1674C19.3034 15.1674 19.2432 16.1136 18.7882 17.2036C18.3333 18.2937 18.1168 18.8446 18.1168 18.8446C18.1168 18.8446 18.5588 19.8614 17.8699 20.1926C17.181 20.5237 16.7222 19.4473 16.7222 19.4473" stroke="#1F1F1F" stroke-width="0.324036" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M11.0735 16.7776C11.0735 16.7776 10.1215 21.3977 11.9899 21.283C14.5932 21.1236 12.511 10.4239 11.6089 10.5004L6.35364 12.1148C5.5753 12.2334 2.09881 19.8385 4.56732 19.6071C6.19592 19.4541 6.90233 16.1272 6.90233 16.1272" fill="white"></path>
|
||||
<path d="M11.0735 16.7776C11.0735 16.7776 10.1215 21.3977 11.9899 21.283C14.5932 21.1236 12.511 10.4239 11.6089 10.5004L6.35364 12.1148C5.5753 12.2334 2.09881 19.8385 4.56732 19.6071C6.19592 19.4541 6.90233 16.1272 6.90233 16.1272" stroke="#1F1F1F" stroke-width="0.324036" stroke-miterlimit="10" stroke-linecap="round"></path>
|
||||
<path d="M6.02648 6.27214C6.02648 6.27214 4.81783 2.8231 6.02648 2.30788C7.23514 1.79267 7.82877 4.29034 7.82877 4.29034L10.8287 4.37977C10.8287 4.37977 11.7794 1.66111 13.012 2.12318C14.2447 2.58526 12.7813 6.41472 12.7813 6.41472C12.7813 6.41472 12.9297 13.6828 6.02972 12.1495C6.02972 12.1495 2.81852 11.9875 6.02648 6.27279V6.27214Z" fill="white" stroke="#1F1F1F" stroke-width="0.324036" stroke-miterlimit="10"></path>
|
||||
<path d="M7.73984 6.93979C7.57004 6.93979 7.54801 6.71166 7.67244 6.65074C7.62837 6.60992 7.57782 6.58594 7.52338 6.58594C7.35942 6.58594 7.22656 6.80045 7.22656 7.06551C7.22656 7.33057 7.35942 7.54508 7.52338 7.54508C7.68734 7.54508 7.8202 7.33057 7.8202 7.06551C7.8202 7.01626 7.81566 6.9696 7.80723 6.92488C7.78779 6.9346 7.76576 6.94043 7.73919 6.94043L7.73984 6.93979Z" fill="#1F1F1F"></path>
|
||||
<path d="M9.54452 6.93978C9.37473 6.93978 9.35269 6.71166 9.47712 6.65074C9.43305 6.60992 9.3825 6.58594 9.32807 6.58594C9.1641 6.58594 9.03125 6.80045 9.03125 7.06551C9.03125 7.33057 9.1641 7.54508 9.32807 7.54508C9.49203 7.54508 9.62488 7.33057 9.62488 7.06551C9.62488 7.01626 9.62035 6.9696 9.61192 6.92488C9.59248 6.9346 9.57045 6.94043 9.54387 6.94043L9.54452 6.93978Z" fill="#1F1F1F"></path>
|
||||
<path d="M6.52068 9.62708C6.4319 8.94596 5.84863 8.64072 5.25046 8.45666C4.93291 8.35881 4.73589 8.65497 4.77802 8.90837C4.43389 9.24796 4.48444 9.9077 4.49027 10.3354C4.49351 10.6083 4.51036 10.8746 4.80135 10.9848C5.06706 11.0852 5.44553 11.0567 5.72161 11.0016C6.33015 10.8811 6.59456 10.1922 6.52133 9.62643L6.52068 9.62708Z" fill="#1F1F1F"></path>
|
||||
<path d="M5.87559 12.0859C5.87559 12.0859 5.4686 12.6679 6.11991 12.942C6.77123 13.2162 7.43096 12.3095 7.43096 12.3095L5.87559 12.0859Z" fill="#E2B4CC" stroke="#1F1F1F" stroke-width="0.324036" stroke-miterlimit="10"></path>
|
||||
<path d="M6.11634 9.17721C6.08199 9.14805 6.03987 9.1286 5.99774 9.11305C5.95562 9.0975 5.9109 9.08972 5.86618 9.08648C5.82989 9.08389 5.79166 9.09037 5.76184 9.11046C5.74305 9.11694 5.7262 9.1286 5.71324 9.1474C5.68991 9.18175 5.68991 9.2297 5.71 9.26535C5.73074 9.30164 5.76703 9.32367 5.80397 9.33988C5.83961 9.35608 5.87655 9.36969 5.91479 9.37876C5.94525 9.38589 5.98024 9.39043 6.01459 9.38913C6.0282 9.38978 6.04116 9.38783 6.05283 9.38395C6.09236 9.37552 6.12736 9.35608 6.14809 9.31719C6.17402 9.26924 6.15782 9.21091 6.11699 9.17656L6.11634 9.17721Z" fill="white"></path></svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -40,6 +40,10 @@ abstract final class AppAssets {
|
||||
static const String addPhoto = 'assets/svg/common/add_photo.svg';
|
||||
static const String addCard = 'assets/svg/common/add_card.svg';
|
||||
|
||||
/// 마이페이지 통계 줄의 그림 아이콘 — 반려동물 / 명함 관리
|
||||
static const String artRup = 'assets/svg/common/art_rup.svg';
|
||||
static const String artCard = 'assets/svg/common/art_card.svg';
|
||||
|
||||
// 브랜드 로고 (명함 헤더 등)
|
||||
static const String logo = 'assets/images/brand/rup_logo.png';
|
||||
static const String logoWhite = 'assets/images/brand/rup_logo_white.png';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/// 앱 정보 — 화면에 보이는 버전 표기.
|
||||
/// pubspec.yaml 의 version(빌드 번호 제외)과 맞춘다.
|
||||
const String kAppVersion = '1.0.0';
|
||||
@@ -7,7 +7,13 @@ import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
|
||||
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/policy_doc_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart';
|
||||
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_page.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart';
|
||||
@@ -131,8 +137,38 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.myPage,
|
||||
builder: (context, state) =>
|
||||
const TabPlaceholderPage(title: '마이페이지'),
|
||||
builder: (context, state) => const MyPage(),
|
||||
// 탭바를 둔 채 넘어가는 하위 화면
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'pets',
|
||||
builder: (context, state) => const MyPetsPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':petId',
|
||||
builder: (context, state) =>
|
||||
PetInfoPage(petId: state.pathParameters['petId']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'policy/:doc',
|
||||
// 모르는 문서 이름이면 설정으로 돌려보낸다
|
||||
redirect: (context, state) =>
|
||||
PolicyDoc.byName(state.pathParameters['doc']) == null
|
||||
? AppRoutes.settings
|
||||
: null,
|
||||
builder: (context, state) => PolicyDocPage(
|
||||
doc: PolicyDoc.byName(state.pathParameters['doc'])!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:tma_app/features/entry/domain/entry_state.dart';
|
||||
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
|
||||
|
||||
/// 라우트 경로 상수
|
||||
abstract final class AppRoutes {
|
||||
@@ -13,6 +14,12 @@ abstract final class AppRoutes {
|
||||
static const String petCards = '/pet-cards';
|
||||
static const String myPage = '/mypage';
|
||||
|
||||
// 마이페이지 하위 화면 (탭바 유지)
|
||||
static const String myPets = '/mypage/pets';
|
||||
static String petInfo(String petId) => '/mypage/pets/$petId';
|
||||
static const String settings = '/mypage/settings';
|
||||
static String policy(PolicyDoc doc) => '/mypage/settings/policy/${doc.name}';
|
||||
|
||||
// 탭 위에 덮는 화면 (탭바 없음)
|
||||
static const String petRegister = '/pets/new';
|
||||
static const String petEditPattern = '/pets/:petId/edit';
|
||||
|
||||
@@ -13,6 +13,9 @@ abstract interface class AuthRepository {
|
||||
/// 인사말에 쓰는 이름 — 소셜 계정 프로필에서 받는다. 모르면 null
|
||||
String? get nickname;
|
||||
|
||||
/// 로그인한 소셜 계정 종류 — 로그인 전이면 null
|
||||
SocialProvider? get provider;
|
||||
|
||||
Future<void> signIn(SocialProvider provider);
|
||||
|
||||
Future<void> signOut();
|
||||
@@ -34,6 +37,10 @@ class DemoAuthRepository implements AuthRepository {
|
||||
@override
|
||||
String? get nickname => null;
|
||||
|
||||
@override
|
||||
SocialProvider? get provider =>
|
||||
SocialProvider.values.asNameMap()[_prefs.getString(_kDemoSessionKey)];
|
||||
|
||||
@override
|
||||
Future<void> signIn(SocialProvider provider) async {
|
||||
await _prefs.setString(_kDemoSessionKey, provider.name);
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
/// 지원하는 소셜 로그인 제공자
|
||||
enum SocialProvider { naver, kakao, google, apple }
|
||||
enum SocialProvider {
|
||||
naver('네이버'),
|
||||
kakao('카카오'),
|
||||
google('구글'),
|
||||
apple('Apple');
|
||||
|
||||
const SocialProvider(this.label);
|
||||
|
||||
/// 화면 표기 — "카카오 계정으로 로그인했어요"
|
||||
final String label;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
|
||||
import 'package:tma_app/features/auth/domain/social_provider.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
|
||||
/// 이름을 모를 때 부르는 호칭 — 로그인 화면의 "반가워요, 집사님!" 과 맞춘다
|
||||
const String kDefaultNickname = '집사';
|
||||
|
||||
/// 인사말에 쓰는 사용자 이름 (로그인·로그아웃하면 다시 읽는다)
|
||||
/// 인사말에 쓰는 사용자 이름 — 앱에서 바꾼 닉네임 > 소셜 계정 이름 > 기본 호칭
|
||||
final userNicknameProvider = Provider<String>((ref) {
|
||||
ref.watch(entryProvider.select((s) => s.signedIn));
|
||||
return ref.watch(authRepositoryProvider).nickname ?? kDefaultNickname;
|
||||
return ref.watch(profileProvider).nickname ??
|
||||
ref.watch(authRepositoryProvider).nickname ??
|
||||
kDefaultNickname;
|
||||
});
|
||||
|
||||
/// 로그인한 소셜 계정 종류
|
||||
final signedInProviderProvider = Provider<SocialProvider?>((ref) {
|
||||
ref.watch(entryProvider.select((s) => s.signedIn));
|
||||
return ref.watch(authRepositoryProvider).provider;
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ class HomePage extends ConsumerWidget {
|
||||
title: '우리 아이',
|
||||
sub: true,
|
||||
trailing: '아이 상세보기',
|
||||
onTrailingTap: () => context.go(AppRoutes.myPage),
|
||||
onTrailingTap: () => context.go(AppRoutes.myPets),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,11 +19,13 @@ class HomeScheduleSection extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 시안: 이 섹션 제목만 기본 위 여백(26)이 살아 있어 다른 섹션보다 조금 아래에 있다
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: '일정', trailing: '전체보기', onTrailingTap: onSeeAll),
|
||||
),
|
||||
const SizedBox(height: 17),
|
||||
const SizedBox(height: 21),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
|
||||
/// 마이페이지 설정 스위치 — 앱 안의 선호값이다.
|
||||
/// (카메라·갤러리 권한 자체는 OS 가 관리하므로, 실제 권한 요청은 해당 기능을 쓸 때 한다)
|
||||
enum AppSetting {
|
||||
camera('settings.camera', true),
|
||||
gallery('settings.gallery', true),
|
||||
scheduleNotification('settings.notiSchedule', true),
|
||||
eventNotification('settings.notiEvent', false),
|
||||
batteryOptimizationExcluded('settings.batteryOpt', false);
|
||||
|
||||
const AppSetting(this.key, this.defaultValue);
|
||||
|
||||
final String key;
|
||||
final bool defaultValue;
|
||||
}
|
||||
|
||||
class AppSettingsNotifier extends Notifier<Map<AppSetting, bool>> {
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
@override
|
||||
Map<AppSetting, bool> build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return {for (final s in AppSetting.values) s: prefs.getBool(s.key) ?? s.defaultValue};
|
||||
}
|
||||
|
||||
Future<void> set(AppSetting setting, bool value) async {
|
||||
state = {...state, setting: value};
|
||||
await _prefs.setBool(setting.key, value);
|
||||
}
|
||||
}
|
||||
|
||||
final appSettingsProvider = NotifierProvider<AppSettingsNotifier, Map<AppSetting, bool>>(
|
||||
AppSettingsNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
|
||||
const String _kNicknameKey = 'profile.nickname';
|
||||
const String _kPhotoKey = 'profile.photo';
|
||||
|
||||
/// 내 프로필 — 앱에서 바꾼 닉네임·사진 (없으면 소셜 계정 값 / 기본값을 쓴다)
|
||||
class UserProfile {
|
||||
const UserProfile({this.nickname, this.photoPath});
|
||||
|
||||
final String? nickname;
|
||||
final String? photoPath;
|
||||
}
|
||||
|
||||
/// 프로필 저장 — 서버 회원 API 가 생기면 그쪽으로 옮긴다
|
||||
class ProfileRepository {
|
||||
ProfileRepository(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
UserProfile load() => UserProfile(
|
||||
nickname: _prefs.getString(_kNicknameKey),
|
||||
photoPath: _prefs.getString(_kPhotoKey),
|
||||
);
|
||||
|
||||
Future<void> save(UserProfile profile) async {
|
||||
final nickname = profile.nickname;
|
||||
final photo = profile.photoPath;
|
||||
nickname == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, nickname);
|
||||
photo == null ? await _prefs.remove(_kPhotoKey) : await _prefs.setString(_kPhotoKey, photo);
|
||||
}
|
||||
}
|
||||
|
||||
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
|
||||
return ProfileRepository(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
|
||||
class ProfileNotifier extends Notifier<UserProfile> {
|
||||
@override
|
||||
UserProfile build() => ref.watch(profileRepositoryProvider).load();
|
||||
|
||||
/// 넘긴 값만 바꾼다 (null 이면 기존 값 유지)
|
||||
Future<void> update({String? nickname, String? photoPath}) async {
|
||||
final next = UserProfile(
|
||||
nickname: nickname ?? state.nickname,
|
||||
photoPath: photoPath ?? state.photoPath,
|
||||
);
|
||||
await ref.read(profileRepositoryProvider).save(next);
|
||||
state = next;
|
||||
}
|
||||
}
|
||||
|
||||
final profileProvider = NotifierProvider<ProfileNotifier, UserProfile>(ProfileNotifier.new);
|
||||
@@ -0,0 +1,46 @@
|
||||
/// 약관 문서의 한 조항
|
||||
class PolicySection {
|
||||
const PolicySection(this.heading, this.body);
|
||||
|
||||
final String heading;
|
||||
final String body;
|
||||
}
|
||||
|
||||
/// 설정 > 약관 및 정책 문서.
|
||||
/// 시안 문구를 그대로 옮긴 초안이다 — 정식 문서가 확정되면 서버/웹 문서로 교체한다.
|
||||
enum PolicyDoc {
|
||||
privacy(
|
||||
title: '개인정보 처리방침',
|
||||
effective: '2026년 1월 1일 시행',
|
||||
sections: [
|
||||
PolicySection('1. 수집하는 개인정보 항목', '이메일 주소, 반려동물 정보(이름·품종·생년월일·건강 기록), 기기 정보 및 알림 토큰을 수집합니다.'),
|
||||
PolicySection('2. 개인정보의 이용 목적', '일정 알림 제공, 펫 명함 생성·공유, 서비스 개선 및 고객 문의 응대를 위해 이용합니다.'),
|
||||
PolicySection('3. 보유 및 이용 기간', '회원 탈퇴 시 지체 없이 파기하며, 관계 법령에 따라 보관이 필요한 경우 해당 기간 동안 안전하게 보관합니다.'),
|
||||
PolicySection('4. 제3자 제공', 'rup은 이용자의 동의 없이 개인정보를 외부에 제공하지 않습니다.'),
|
||||
PolicySection('5. 이용자의 권리', '이용자는 언제든지 개인정보 열람·정정·삭제 및 처리정지를 요청할 수 있습니다.'),
|
||||
],
|
||||
),
|
||||
terms(
|
||||
title: '이용약관',
|
||||
effective: '2026년 1월 1일 시행',
|
||||
sections: [
|
||||
PolicySection('제1조 (목적)', '본 약관은 rup이 제공하는 반려동물 일정관리 서비스의 이용 조건 및 절차를 규정합니다.'),
|
||||
PolicySection('제2조 (서비스 내용)', '일정·알림 관리, 펫 명함 생성·공유, 다이어리 기록 기능을 제공합니다.'),
|
||||
PolicySection('제3조 (이용자의 의무)', '이용자는 타인의 정보를 도용하거나 서비스의 정상적인 운영을 방해하는 행위를 해서는 안 됩니다.'),
|
||||
PolicySection('제4조 (콘텐츠의 권리)', '이용자가 작성한 다이어리·사진의 저작권은 이용자에게 있으며, rup은 서비스 제공 목적 범위 내에서만 이를 이용합니다.'),
|
||||
PolicySection('제5조 (책임의 제한)', 'rup은 천재지변 또는 이용자의 귀책사유로 발생한 서비스 장애에 대해 책임을 지지 않습니다.'),
|
||||
],
|
||||
);
|
||||
|
||||
const PolicyDoc({required this.title, required this.effective, required this.sections});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 시행일 문구
|
||||
final String effective;
|
||||
final List<PolicySection> sections;
|
||||
|
||||
/// 경로의 문서 이름 → 문서 (모르는 이름이면 null)
|
||||
static PolicyDoc? byName(String? name) =>
|
||||
PolicyDoc.values.where((d) => d.name == name).firstOrNull;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/constants/app_info.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
import 'package:tma_app/core/router/app_routes.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/auth/presentation/providers/user_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/widgets/profile_edit_sheet.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/widgets/settings_row.dart';
|
||||
import 'package:tma_app/features/pet/domain/pet.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_logo.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
|
||||
const double _kBottomGap = 30;
|
||||
|
||||
/// 스위치를 바꿨을 때 보여 줄 안내 (켰을 때, 껐을 때)와 아이콘
|
||||
typedef _ToggleCopy = ({String on, String off, RupIconName icon});
|
||||
|
||||
const Map<AppSetting, _ToggleCopy> _kToggleCopy = {
|
||||
AppSetting.camera: (on: '카메라 권한을 허용했어요', off: '카메라 권한을 껐어요', icon: RupIconName.camera),
|
||||
AppSetting.gallery: (on: '갤러리 저장을 허용했어요', off: '갤러리 저장을 껐어요', icon: RupIconName.download),
|
||||
AppSetting.scheduleNotification: (on: '일정·다이어리 알림을 켰어요', off: '일정·다이어리 알림을 껐어요', icon: RupIconName.bell),
|
||||
AppSetting.eventNotification: (on: '혜택·이벤트 알림을 켰어요', off: '혜택·이벤트 알림을 껐어요', icon: RupIconName.sparkle),
|
||||
AppSetting.batteryOptimizationExcluded: (on: '배터리 최적화에서 제외했어요', off: '배터리 최적화에 포함했어요', icon: RupIconName.battery),
|
||||
};
|
||||
|
||||
/// 마이페이지 탭 — 내 프로필, 반려동물·명함 바로가기, 앱 설정
|
||||
class MyPage extends ConsumerWidget {
|
||||
const MyPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
|
||||
Future<void> toggle(AppSetting setting, bool value) async {
|
||||
await ref.read(appSettingsProvider.notifier).set(setting, value);
|
||||
final copy = _kToggleCopy[setting]!;
|
||||
ref.read(toastProvider.notifier).show(value ? copy.on : copy.off, icon: copy.icon);
|
||||
}
|
||||
|
||||
SettingsRow row(AppSetting setting, String title, RupIconName icon, Color tint, {bool first = false}) =>
|
||||
SettingsRow(
|
||||
icon: icon,
|
||||
tint: tint,
|
||||
title: title,
|
||||
first: first,
|
||||
toggleValue: settings[setting],
|
||||
onToggle: (v) => unawaited(toggle(setting, v)),
|
||||
);
|
||||
|
||||
Widget padded(Widget child) =>
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), child: child);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: AppSpacing.xs,
|
||||
bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap,
|
||||
),
|
||||
children: [
|
||||
padded(_Header(onSettings: () => unawaited(context.push(AppRoutes.settings)))),
|
||||
const SizedBox(height: 18),
|
||||
padded(const _ProfileCard()),
|
||||
const DividerBand(),
|
||||
padded(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: '설정'),
|
||||
),
|
||||
const SettingsGroupLabel('앱 권한 설정', top: 14),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
row(AppSetting.camera, '카메라 접근 권한', RupIconName.camera, SettingsTints.camera, first: true),
|
||||
row(AppSetting.gallery, '갤러리 저장 허용', RupIconName.download, SettingsTints.gallery),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SettingsGroupLabel('알림 설정'),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
row(AppSetting.scheduleNotification, '일정·다이어리 알림', RupIconName.bell, SettingsTints.schedule,
|
||||
first: true),
|
||||
row(AppSetting.eventNotification, '혜택 및 이벤트 알림', RupIconName.sparkle, SettingsTints.event),
|
||||
row(AppSetting.batteryOptimizationExcluded, '배터리 최적화 제외', RupIconName.battery,
|
||||
SettingsTints.battery),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 56),
|
||||
const Center(child: RupLogo(height: 22)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'버전 $kAppVersion',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 11, color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.onSettings});
|
||||
|
||||
final VoidCallback onSettings;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Text('마이페이지', style: Theme.of(context).textTheme.headlineSmall)),
|
||||
Pressable(
|
||||
onTap: onSettings,
|
||||
pressedScale: .9,
|
||||
semanticLabel: '설정',
|
||||
child: const SizedBox.square(
|
||||
dimension: 42,
|
||||
child: Center(child: RupGlyphIcon(RupGlyph.gear, size: 23, color: AppColors.ink2)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 내 프로필 + 반려동물·명함 관리 바로가기 — 한 상자
|
||||
class _ProfileCard extends ConsumerWidget {
|
||||
const _ProfileCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final nickname = ref.watch(userNicknameProvider);
|
||||
final photo = ref.watch(profileProvider).photoPath;
|
||||
final provider = ref.watch(signedInProviderProvider);
|
||||
final petCount = (ref.watch(petListProvider).value ?? const <Pet>[]).length;
|
||||
final cardCount = ref.watch(myCardsProvider).length;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Row(
|
||||
children: [
|
||||
PersonAvatar(size: 58, photoPath: photo),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
nickname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall!.copyWith(fontSize: 18, fontWeight: FontWeight.w800, height: _kNormal),
|
||||
),
|
||||
if (provider != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${provider.label} 계정으로 로그인했어요',
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
RupSoftPillButton(
|
||||
label: '편집',
|
||||
showPlus: false,
|
||||
background: AppColors.primary50,
|
||||
onPressed: () => unawaited(showProfileEditSheet(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatTile(
|
||||
art: SvgPicture.asset(AppAssets.artRup, width: 40, height: 40),
|
||||
title: '반려동물',
|
||||
value: '$petCount마리',
|
||||
onTap: () => context.go(AppRoutes.myPets),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, thickness: 1, color: AppColors.line),
|
||||
Expanded(
|
||||
child: _StatTile(
|
||||
art: SvgPicture.asset(AppAssets.artCard, width: 34, height: 34),
|
||||
title: '명함 관리',
|
||||
value: '$cardCount장',
|
||||
onTap: () => unawaited(context.push(AppRoutes.petCardManage)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatTile extends StatelessWidget {
|
||||
const _StatTile({required this.art, required this.title, required this.value, required this.onTap});
|
||||
|
||||
final Widget art;
|
||||
final String title;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
label: '$title $value',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: AppSpacing.lg),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
art,
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: textTheme.titleSmall!.copyWith(fontSize: 16.5, fontWeight: FontWeight.w700, height: 1.2),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/theme/app_typography.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/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.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_card.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
|
||||
|
||||
/// 칩 글자색을 만들 때 섞는 짙은 색 (시안 #2A2622)
|
||||
const Color _kTintInk = Color(0xFF2A2622);
|
||||
const double _kEdgeWidth = 4;
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 나의 반려동물 — 등록한 아이 목록, 누르면 반려동물 정보
|
||||
class MyPetsPage extends ConsumerWidget {
|
||||
const MyPetsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
|
||||
final today = DateTime.now();
|
||||
|
||||
return TabSubPage(
|
||||
children: [
|
||||
ScreenPadding(
|
||||
child: SubPageHeader(
|
||||
title: '나의 반려동물',
|
||||
subtitle: '${pets.length}마리와 함께해요',
|
||||
displayFont: true,
|
||||
onBack: () => context.pop(),
|
||||
trailing: RupSoftPillButton(
|
||||
label: '아이 등록하기',
|
||||
onPressed: () => unawaited(context.push(AppRoutes.petRegister)),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pets.isEmpty)
|
||||
const ScreenPadding(child: RupCard(child: EmptyMessage('등록된 반려동물이 없어요', padding: 30)))
|
||||
else
|
||||
for (final (i, pet) in pets.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: AppSpacing.md),
|
||||
ScreenPadding(
|
||||
child: _PetRow(
|
||||
pet: pet,
|
||||
today: today,
|
||||
onTap: () => context.go(AppRoutes.petInfo(pet.id)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PetRow extends StatelessWidget {
|
||||
const _PetRow({required this.pet, required this.today, required this.onTap});
|
||||
|
||||
final Pet pet;
|
||||
final DateTime today;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final species = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
|
||||
final chips = [petAgeLabel(pet.birthDate, today), pet.weightLabel, ?pet.bloodType];
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: RupCard(
|
||||
onTap: onTap,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 아이 고유색 띠
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _kEdgeWidth,
|
||||
child: ColoredBox(color: pet.tint),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Row(
|
||||
children: [
|
||||
PetAvatar(pet: pet, size: 58, ring: true),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
pet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall!.copyWith(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
_GenderPill(pet: pet),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
species,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final c in chips) _TintChip(label: c, tint: pet.tint)],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 성별 알약 — 아이 색을 옅게 깐 바탕
|
||||
class _GenderPill extends StatelessWidget {
|
||||
const _GenderPill({required this.pet});
|
||||
|
||||
final Pet pet;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = Color.lerp(pet.tint, _kTintInk, .30)!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(5, 2, 7, 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(pet.tint, Colors.white, .82),
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RupIcon(pet.gender == PetGender.male ? RupIconName.male : RupIconName.female, size: 11, stroke: 2.4, color: fg),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
pet.gender.label,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: fg,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 나이·몸무게·혈액형 칩
|
||||
class _TintChip extends StatelessWidget {
|
||||
const _TintChip({required this.label, required this.tint});
|
||||
|
||||
final String label;
|
||||
final Color tint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3.5),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(tint, Colors.white, .86),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color.lerp(tint, _kTintInk, .32),
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
import 'package:tma_app/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
|
||||
|
||||
/// 약관 및 정책 문서 (개인정보 처리방침 · 이용약관)
|
||||
class PolicyDocPage extends StatelessWidget {
|
||||
const PolicyDocPage({super.key, required this.doc});
|
||||
|
||||
final PolicyDoc doc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return TabSubPage(
|
||||
children: [
|
||||
ScreenPadding(
|
||||
child: SubPageHeader(title: doc.title, subtitle: doc.effective, onBack: () => context.pop()),
|
||||
),
|
||||
ScreenPadding(
|
||||
child: RupCard(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, s) in doc.sections.indexed) ...[
|
||||
if (i > 0) const SizedBox(height: 18),
|
||||
Text(
|
||||
s.heading,
|
||||
style: textTheme.titleSmall!.copyWith(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.ink,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
s.body,
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.7, color: AppColors.ink2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/theme/app_typography.dart';
|
||||
import 'package:tma_app/features/auth/presentation/providers/user_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/providers/account_actions.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/widgets/settings_row.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_dialog.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 회원탈퇴 확인창 아이콘 색 (시안 #E05A4A)
|
||||
const Color _kWithdrawTone = Color(0xFFE05A4A);
|
||||
|
||||
/// 설정 — 현재 계정, 약관 및 정책, 계정 변경·로그아웃·회원탈퇴
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '로그아웃할까요?',
|
||||
message: '기록은 그대로 남아 있어요.\n다시 로그인하면 이어서 볼 수 있어요.',
|
||||
confirmLabel: '로그아웃',
|
||||
layout: RupConfirmLayout.row,
|
||||
icon: RupIconName.logout,
|
||||
iconBackground: AppColors.muted.withValues(alpha: .15),
|
||||
);
|
||||
if (ok) await ref.read(accountActionsProvider).logout();
|
||||
}
|
||||
|
||||
Future<void> _withdraw(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showRupConfirm(
|
||||
context,
|
||||
title: '정말 탈퇴할까요?',
|
||||
message: '아이들의 일정·다이어리·펫 명함이 모두 삭제되고\n되돌릴 수 없어요.',
|
||||
confirmLabel: '탈퇴하기',
|
||||
layout: RupConfirmLayout.row,
|
||||
icon: RupIconName.alert,
|
||||
iconColor: _kWithdrawTone,
|
||||
);
|
||||
if (ok) await ref.read(accountActionsProvider).withdraw();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final nickname = ref.watch(userNicknameProvider);
|
||||
final photo = ref.watch(profileProvider).photoPath;
|
||||
final provider = ref.watch(signedInProviderProvider);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return TabSubPage(
|
||||
children: [
|
||||
ScreenPadding(
|
||||
child: SubPageHeader(title: '설정', subtitle: '계정과 약관을 관리해요', onBack: () => context.pop()),
|
||||
),
|
||||
ScreenPadding(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
RupCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
PersonAvatar(size: 46, photoPath: photo),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
nickname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall!.copyWith(fontSize: 15.5, fontWeight: FontWeight.w800, height: _kNormal),
|
||||
),
|
||||
if (provider != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${provider.label} 계정으로 로그인했어요',
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SettingsGroupLabel('약관 및 정책'),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsRow(
|
||||
icon: RupIconName.shield,
|
||||
tint: SettingsTints.privacy,
|
||||
title: PolicyDoc.privacy.title,
|
||||
first: true,
|
||||
onTap: () => context.go(AppRoutes.policy(PolicyDoc.privacy)),
|
||||
),
|
||||
SettingsRow(
|
||||
icon: RupIconName.text,
|
||||
tint: SettingsTints.neutral,
|
||||
title: PolicyDoc.terms.title,
|
||||
onTap: () => context.go(AppRoutes.policy(PolicyDoc.terms)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SettingsGroupLabel('계정'),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
// 계정 변경 = 로그아웃 후 다른 계정으로 로그인 (시안도 확인 없이 바로 나간다)
|
||||
SettingsRow(
|
||||
icon: RupIconName.users,
|
||||
tint: SettingsTints.account,
|
||||
title: '계정 변경',
|
||||
detail: '다른 계정으로 로그인',
|
||||
first: true,
|
||||
onTap: () => unawaited(ref.read(accountActionsProvider).logout()),
|
||||
),
|
||||
SettingsRow(
|
||||
icon: RupIconName.logout,
|
||||
tint: SettingsTints.neutral,
|
||||
title: '로그아웃',
|
||||
onTap: () => unawaited(_logout(context, ref)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 34),
|
||||
Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => unawaited(_withdraw(context, ref)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
||||
child: Text(
|
||||
'회원탈퇴',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.muted,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
|
||||
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
|
||||
|
||||
/// 계정 동작 — 로그아웃 / 회원탈퇴
|
||||
class AccountActions {
|
||||
AccountActions(this._ref);
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// 로그아웃 — 기록은 기기에 남는다. 라우터가 로그인 화면으로 보낸다
|
||||
Future<void> logout() => _ref.read(entryProvider.notifier).signOut();
|
||||
|
||||
/// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다.
|
||||
/// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다.
|
||||
Future<void> withdraw() async {
|
||||
// 기록 삭제와 화면 전환이 먼저다 — 사진 폴더 정리가 늦거나 실패해도 탈퇴는 끝나야 한다
|
||||
final photos = _ref.read(petPhotoStoreProvider);
|
||||
await _ref.read(sharedPreferencesProvider).clear();
|
||||
// 캐시된 상태를 버리고 빈 저장소에서 다시 읽게 한다 (진입 상태가 바뀌면 라우터가 인트로로 보낸다)
|
||||
_ref
|
||||
..invalidate(petListProvider)
|
||||
..invalidate(selectedPetIdProvider)
|
||||
..invalidate(petCardsProvider)
|
||||
..invalidate(userBackgroundsProvider)
|
||||
..invalidate(profileProvider)
|
||||
..invalidate(appSettingsProvider)
|
||||
..invalidate(entryProvider);
|
||||
try {
|
||||
await photos.clearAll();
|
||||
} on Exception {
|
||||
// 남은 사진 파일은 가리키는 기록이 없어 다시 보이지 않고, 앱을 지우면 함께 지워진다
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final accountActionsProvider = Provider<AccountActions>(AccountActions.new);
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.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/auth/presentation/providers/user_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_sheet.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_toast.dart';
|
||||
|
||||
const double _kPhotoSize = 96;
|
||||
const int _kNicknameMaxLength = 12;
|
||||
|
||||
/// 프로필 사진은 작게 보이므로 더 줄여 저장한다
|
||||
const double _kPhotoMaxSide = 800;
|
||||
const int _kPhotoQuality = 85;
|
||||
|
||||
|
||||
/// 프로필 편집 시트 — 닉네임 · 사진
|
||||
Future<void> showProfileEditSheet(BuildContext context) {
|
||||
return showRupSheet<void>(
|
||||
context,
|
||||
title: '프로필 편집',
|
||||
flushBottom: true,
|
||||
builder: (context) => const _ProfileEditSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ProfileEditSheet extends ConsumerStatefulWidget {
|
||||
const _ProfileEditSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_ProfileEditSheet> createState() => _ProfileEditSheetState();
|
||||
}
|
||||
|
||||
class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
|
||||
late final TextEditingController _name = TextEditingController(text: ref.read(userNicknameProvider));
|
||||
late String? _photoPath = ref.read(profileProvider).photoPath;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _name.text.trim();
|
||||
// 비워 두면 닉네임은 바꾸지 않는다 (시안: 값이 있을 때만 반영)
|
||||
await ref.read(profileProvider.notifier).update(
|
||||
nickname: name.isEmpty ? null : name,
|
||||
photoPath: _photoPath,
|
||||
);
|
||||
ref.read(toastProvider.notifier).show('프로필을 수정했어요', icon: RupIconName.check);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = _photoPath;
|
||||
return SingleChildScrollView(
|
||||
// 바닥 버튼이 좌우 여백 밖까지 그려지도록 자르지 않는다
|
||||
clipBehavior: Clip.none,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 시안: 제목 아래 여백(14)과 사진 위 여백(10)은 겹쳐서 14 만 남는다
|
||||
Center(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: photo == null ? '프로필 사진 추가하기' : '프로필 사진 바꾸기',
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(_pickPhoto()),
|
||||
child: photo == null
|
||||
? Container(
|
||||
width: _kPhotoSize,
|
||||
height: _kPhotoSize,
|
||||
decoration: const BoxDecoration(color: AppColors.sand, shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const _AddPhotoIcon(),
|
||||
)
|
||||
: PersonAvatar(size: _kPhotoSize, photoPath: photo),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'사진을 눌러 프로필 이미지를 바꿔요',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
RupField(
|
||||
label: '닉네임',
|
||||
child: RupTextField(
|
||||
controller: _name,
|
||||
hintText: '예: 나영',
|
||||
maxLength: _kNicknameMaxLength,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => unawaited(_submit()),
|
||||
),
|
||||
),
|
||||
RupSheetBleedButton(label: '수정 완료', onPressed: () => unawaited(_submit())),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddPhotoIcon extends StatelessWidget {
|
||||
const _AddPhotoIcon();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SvgPicture.asset(
|
||||
AppAssets.addPhoto,
|
||||
width: 30,
|
||||
height: 30,
|
||||
colorFilter: const ColorFilter.mode(AppColors.sandInk, BlendMode.srcIn),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/shared/widgets/heart_toggle.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 설정 줄 아이콘 색 (시안 MyPageRow tint)
|
||||
abstract final class SettingsTints {
|
||||
static const Color camera = Color(0xFF4DA6E0);
|
||||
static const Color gallery = Color(0xFF3FB58C);
|
||||
static const Color schedule = Color(0xFFFFAA4D);
|
||||
static const Color event = Color(0xFFF2974E);
|
||||
static const Color battery = Color(0xFF8E7CF0);
|
||||
static const Color privacy = Color(0xFF3FB58C);
|
||||
static const Color neutral = Color(0xFF9AA0A6);
|
||||
static const Color account = Color(0xFF4DA6E0);
|
||||
}
|
||||
|
||||
/// 설정 한 줄 (시안 MyPageRow) — 색 아이콘 + 제목 + (보조 문구) + › 또는 스위치
|
||||
class SettingsRow extends StatelessWidget {
|
||||
const SettingsRow({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tint,
|
||||
required this.title,
|
||||
this.detail,
|
||||
this.onTap,
|
||||
this.first = false,
|
||||
this.toggleValue,
|
||||
this.onToggle,
|
||||
});
|
||||
|
||||
final RupIconName icon;
|
||||
final Color tint;
|
||||
final String title;
|
||||
final String? detail;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 카드의 첫 줄이면 위 구분선을 그리지 않는다
|
||||
final bool first;
|
||||
|
||||
/// 스위치 줄이면 값과 콜백을 준다
|
||||
final bool? toggleValue;
|
||||
final ValueChanged<bool>? onToggle;
|
||||
|
||||
bool get _isToggle => toggleValue != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final row = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: .12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RupIcon(icon, size: 18, stroke: 2, color: tint),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(title, style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal)),
|
||||
),
|
||||
if (detail != null) ...[
|
||||
Text(detail!, style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal)),
|
||||
const SizedBox(width: 13),
|
||||
],
|
||||
if (_isToggle)
|
||||
HeartToggle(value: toggleValue!, onChanged: onToggle!, label: title)
|
||||
else
|
||||
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (_isToggle) return row;
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: onTap, child: row),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정 묶음 제목 — "앱 권한 설정", "알림 설정" 등
|
||||
class SettingsGroupLabel extends StatelessWidget {
|
||||
const SettingsGroupLabel(this.text, {super.key, this.top = 26});
|
||||
|
||||
final String text;
|
||||
final double top;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(AppSpacing.xs, top, AppSpacing.xs, AppSpacing.sm),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ class PetPhotoStore {
|
||||
await file.saveTo(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/// 저장한 사진을 모두 지운다 — 회원탈퇴
|
||||
Future<void> clearAll() async {
|
||||
if (kIsWeb) return;
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${docs.path}/$_kPhotoDir');
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
final petPhotoStoreProvider = Provider<PetPhotoStore>((ref) => const PetPhotoStore());
|
||||
|
||||
@@ -42,15 +42,23 @@ class LocalPetRepository implements PetRepository {
|
||||
|
||||
@override
|
||||
Future<Pet> create(PetInput input) async {
|
||||
final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input);
|
||||
await _write([..._read(), pet]);
|
||||
final pets = _read();
|
||||
final pet = Pet.fromInput(
|
||||
'pet-${_clock().microsecondsSinceEpoch}',
|
||||
input,
|
||||
tint: petTintFor(pets.length),
|
||||
);
|
||||
await _write([...pets, 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]);
|
||||
final pets = _read();
|
||||
// 고유색은 앱이 정한 값이라 수정해도 유지한다
|
||||
final tint = pets.firstWhere((p) => p.id == id).tint;
|
||||
final pet = Pet.fromInput(id, input, tint: tint);
|
||||
await _write([for (final p in pets) p.id == id ? pet : p]);
|
||||
return pet;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
import 'package:tma_app/core/utils/color_utils.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
|
||||
/// 아이별 고유색 — 등록 순서대로 돌아가며 배정한다 (시안 buildPet 의 tints)
|
||||
const List<Color> kPetTints = [
|
||||
Color(0xFFFF9B6A), Color(0xFF6FB8E8), Color(0xFFC49A6C), Color(0xFF8E7BC8),
|
||||
Color(0xFF7BBE7B), Color(0xFFE8A23D), Color(0xFF5BB0E0), Color(0xFFFF7AA0),
|
||||
];
|
||||
|
||||
/// [index] 번째로 등록하는 아이의 색
|
||||
Color petTintFor(int index) => kPetTints[index % kPetTints.length];
|
||||
|
||||
enum PetGender {
|
||||
male('남아'),
|
||||
female('여아');
|
||||
@@ -76,6 +88,7 @@ class PetInput {
|
||||
class Pet extends PetInput {
|
||||
const Pet({
|
||||
required this.id,
|
||||
this.tint = const Color(0xFFFF9B6A),
|
||||
required super.name,
|
||||
required super.species,
|
||||
required super.breed,
|
||||
@@ -94,8 +107,10 @@ class Pet extends PetInput {
|
||||
super.watchConditions,
|
||||
});
|
||||
|
||||
factory Pet.fromInput(String id, PetInput input) => Pet(
|
||||
/// [tint] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 색을 넘긴다
|
||||
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A)}) => Pet(
|
||||
id: id,
|
||||
tint: tint,
|
||||
name: input.name,
|
||||
species: input.species,
|
||||
breed: input.breed,
|
||||
@@ -116,6 +131,9 @@ class Pet extends PetInput {
|
||||
|
||||
final String id;
|
||||
|
||||
/// 목록 왼쪽 띠·칩 등에 쓰는 아이의 고유색
|
||||
final Color tint;
|
||||
|
||||
/// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼
|
||||
PetInput withPhoto(String path) => PetInput(
|
||||
name: name,
|
||||
@@ -146,6 +164,7 @@ class Pet extends PetInput {
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'tint': hexOf(tint),
|
||||
'name': name,
|
||||
'species': species,
|
||||
'breed': breed,
|
||||
@@ -166,6 +185,7 @@ class Pet extends PetInput {
|
||||
|
||||
factory Pet.fromJson(Map<String, Object?> json) => Pet(
|
||||
id: json['id']! as String,
|
||||
tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
|
||||
name: json['name']! as String,
|
||||
species: json['species']! as String,
|
||||
breed: json['breed'] as String? ?? '',
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tma_app/core/constants/app_assets.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/theme/app_typography.dart';
|
||||
import 'package:tma_app/core/utils/date_format.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/presentation/providers/pet_providers.dart';
|
||||
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
|
||||
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart' show kUnregistered;
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_card.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
import 'package:tma_app/shared/widgets/sub_page_header.dart';
|
||||
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
|
||||
|
||||
const double _kPhotoSize = 76;
|
||||
const double _kPhotoRadius = 20;
|
||||
const Color _kPhotoBorder = Color(0xFFC8C8C8);
|
||||
const double _kNormal = AppTypography.normalHeight;
|
||||
|
||||
/// 건강 정보 태그 색 (시안 TagField tone)
|
||||
const Color _kAllergyTone = Color(0xFFE0534F);
|
||||
const Color _kConditionTone = Color(0xFFC77A1F);
|
||||
const Color _kWatchTone = Color(0xFF8E5BD0);
|
||||
|
||||
/// 반려동물 정보 — 기본·건강 정보를 읽기 전용으로 보여 주고, 연필을 누르면 수정
|
||||
class PetInfoPage extends ConsumerWidget {
|
||||
const PetInfoPage({super.key, required this.petId});
|
||||
|
||||
final String petId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final loaded = ref.watch(petListProvider).hasValue;
|
||||
final pet = ref.watch(petByIdProvider(petId));
|
||||
if (!loaded) return const Scaffold(body: SizedBox.shrink());
|
||||
if (pet == null) {
|
||||
// 지워졌거나 잘못된 주소 — 목록으로 돌아갈 수 있게 머리는 남긴다
|
||||
return TabSubPage(
|
||||
children: [
|
||||
ScreenPadding(child: SubPageHeader(title: '반려동물 정보', onBack: () => context.go(AppRoutes.myPets))),
|
||||
const EmptyMessage('등록된 아이가 없어요', padding: 60),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final cardCount = ref.watch(myCardsProvider).where((c) => c.petId == pet.id).length;
|
||||
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
|
||||
|
||||
return TabSubPage(
|
||||
children: [
|
||||
ScreenPadding(
|
||||
child: SubPageHeader(
|
||||
title: '반려동물 정보',
|
||||
subtitle: '${pet.name}의 상세 정보',
|
||||
onBack: () => context.pop(),
|
||||
trailing: Pressable(
|
||||
onTap: () => unawaited(context.push(AppRoutes.petEdit(pet.id))),
|
||||
pressedScale: .9,
|
||||
semanticLabel: '${pet.name} 정보 수정',
|
||||
child: const SizedBox.square(
|
||||
dimension: 38,
|
||||
child: Center(child: RupIcon(RupIconName.edit, size: 19, color: AppColors.ink2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ScreenPadding(
|
||||
child: _IdentityCard(
|
||||
pet: pet,
|
||||
cardCount: cardCount,
|
||||
onCardTap: () => unawaited(
|
||||
context.push(cardCount == 0 ? AppRoutes.petCardCreate(pet.id) : AppRoutes.petCardManage),
|
||||
),
|
||||
),
|
||||
),
|
||||
const DividerBand(),
|
||||
_Section(
|
||||
title: '기본 정보',
|
||||
children: [
|
||||
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
|
||||
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
|
||||
_Field(label: '성별', value: gender),
|
||||
_Field(label: '몸무게', value: pet.weightLabel),
|
||||
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
|
||||
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
|
||||
],
|
||||
),
|
||||
const DividerBand(),
|
||||
_Section(
|
||||
title: '건강 정보',
|
||||
children: [
|
||||
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
|
||||
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
|
||||
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
|
||||
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
|
||||
],
|
||||
),
|
||||
if (pet.memo case final memo?) ...[
|
||||
const DividerBand(),
|
||||
_MemoSection(memo: memo),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사진 · 이름 · 종 · 명함 바로가기
|
||||
class _IdentityCard extends StatelessWidget {
|
||||
const _IdentityCard({required this.pet, required this.cardCount, required this.onCardTap});
|
||||
|
||||
final Pet pet;
|
||||
final int cardCount;
|
||||
final VoidCallback onCardTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final photo = pet.photoPath;
|
||||
return RupCard(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: _kPhotoSize,
|
||||
height: _kPhotoSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_kPhotoRadius),
|
||||
border: Border.all(color: _kPhotoBorder, width: 1.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null
|
||||
? SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover)
|
||||
: PetPhotoImage(path: photo),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontFamily: AppFonts.display, fontSize: 22, height: 1, color: AppColors.ink),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}',
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: onCardTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary50,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
border: Border.all(color: AppColors.primary100),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const RupGlyphIcon(RupGlyph.petCard, size: 13, color: AppColors.primary600),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
cardCount > 0 ? '보유 명함 $cardCount개' : '명함 만들기',
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary600,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section({required this.title, required this.children});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenPadding(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: title),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
RupCard(
|
||||
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, child) in children.indexed)
|
||||
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(label, style: _labelStyle(textTheme)),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _labelStyle(TextTheme textTheme) =>
|
||||
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
|
||||
|
||||
/// 태그 목록 항목 — 비어 있으면 "없음"
|
||||
class _TagField extends StatelessWidget {
|
||||
const _TagField({required this.label, required this.items, required this.tone});
|
||||
|
||||
final String label;
|
||||
final List<String> items;
|
||||
final Color tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: items.isEmpty
|
||||
? Text(
|
||||
'없음',
|
||||
textAlign: TextAlign.right,
|
||||
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
|
||||
)
|
||||
: Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 5,
|
||||
runSpacing: AppSpacing.xs,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: tone.withValues(alpha: .1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
item,
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: tone,
|
||||
height: _kNormal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoSection extends StatelessWidget {
|
||||
const _MemoSection({required this.memo});
|
||||
|
||||
final String memo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenPadding(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
|
||||
child: SectionTitle(title: '보호자 메모'),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
RupCard(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text(
|
||||
memo,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -238,11 +238,14 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
|
||||
final pet = await notifier.create(_buildInput());
|
||||
ref.read(selectedPetIdProvider.notifier).select(pet.id);
|
||||
toast.show('${pet.name} 등록 완료!');
|
||||
// 시안: 등록을 마치면 어디서 왔든 "나의 반려동물" 목록으로 간다
|
||||
if (mounted) context.go(AppRoutes.myPets);
|
||||
} else {
|
||||
await notifier.updatePet(editing.id, _buildInput());
|
||||
toast.show('반려동물 정보를 저장했어요');
|
||||
// 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
if (mounted) context.pop();
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
@@ -263,7 +266,7 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
|
||||
// 아이의 펫 명함도 함께 지운다 (일정은 일정 기능을 만들 때 함께 정리한다)
|
||||
await ref.read(petCardsProvider.notifier).deleteForPet(editing.id);
|
||||
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
|
||||
if (mounted) context.go(AppRoutes.myPage);
|
||||
if (mounted) context.go(AppRoutes.myPets);
|
||||
}
|
||||
|
||||
// ── 화면 ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
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/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);
|
||||
|
||||
/// [PetAvatar.ring] — 흰 틈 2.5 + 아이 색 고리 1.5 (시안 box-shadow 0 0 0 2.5px surface, 0 0 0 4px tint)
|
||||
const double _kRingGap = 2.5;
|
||||
const double _kRingOuter = 4;
|
||||
|
||||
/// 반려동물 원형 아바타 — 사진이 없으면 기본 강아지 얼굴 아이콘
|
||||
class PetAvatar extends StatelessWidget {
|
||||
const PetAvatar({super.key, required this.pet, required this.size});
|
||||
const PetAvatar({super.key, required this.pet, required this.size, this.ring = false});
|
||||
|
||||
final Pet pet;
|
||||
final double size;
|
||||
|
||||
/// 바깥에 아이 고유색 고리를 두른다 (나의 반려동물 목록)
|
||||
final bool ring;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = pet.photoPath;
|
||||
@@ -26,6 +34,13 @@ class PetAvatar extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
|
||||
// 뒤에 오는 그림자가 위에 그려진다 — 색 고리 위에 흰 틈을 얹는다
|
||||
boxShadow: ring
|
||||
? [
|
||||
BoxShadow(color: pet.tint, spreadRadius: _kRingOuter),
|
||||
const BoxShadow(color: AppColors.surface, spreadRadius: _kRingGap),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_colors.dart';
|
||||
|
||||
// 시안 .gx-toggle — 44×22, 안쪽 여백 3.5, 흰 판이 좌우로 22 씩 미끄러진다
|
||||
const double _kWidth = 44;
|
||||
const double _kHeight = 22;
|
||||
const double _kInset = 3.5;
|
||||
const double _kShift = 22;
|
||||
const Duration _kSlide = Duration(milliseconds: 850);
|
||||
const Cubic _kSlideCurve = Cubic(.4, .8, .35, 1);
|
||||
const Color _kOffTrack = Color(0xFFECE7DF);
|
||||
|
||||
/// 설정 켜고 끄기 (시안 HeartToggle)
|
||||
class HeartToggle extends StatelessWidget {
|
||||
const HeartToggle({super.key, required this.value, required this.onChanged, required this.label});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const innerWidth = _kWidth - _kInset * 2;
|
||||
return Semantics(
|
||||
container: true,
|
||||
toggled: value,
|
||||
label: label,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: _kWidth,
|
||||
height: _kHeight,
|
||||
padding: const EdgeInsets.all(_kInset),
|
||||
decoration: BoxDecoration(
|
||||
color: value ? AppColors.primary : _kOffTrack,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: _kSlide,
|
||||
curve: _kSlideCurve,
|
||||
left: value ? _kShift : -_kShift,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: innerWidth,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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';
|
||||
|
||||
/// 사람(보호자) 원형 아바타 — 사진이 없으면 기본 사람 아이콘
|
||||
class PersonAvatar extends StatelessWidget {
|
||||
const PersonAvatar({super.key, required this.size, this.photoPath});
|
||||
|
||||
final double size;
|
||||
final String? photoPath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = photoPath;
|
||||
Widget error(BuildContext c, Object e, StackTrace? s) =>
|
||||
SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover);
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(color: AppColors.surface2, shape: BoxShape.circle),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photo == null
|
||||
? SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover)
|
||||
: kIsWeb
|
||||
? Image.network(photo, fit: BoxFit.cover, errorBuilder: error)
|
||||
: Image.file(File(photo), fit: BoxFit.cover, errorBuilder: error),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ class _PressableState extends State<Pressable> {
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = widget.onTap != null;
|
||||
return Semantics(
|
||||
// 버튼마다 따로 읽히게 — 옆의 제목 글자와 한 덩어리로 합쳐지지 않도록
|
||||
container: true,
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: widget.semanticLabel,
|
||||
|
||||
@@ -32,14 +32,23 @@ const double _kPadV = 13;
|
||||
const double _kPadVLarge = 16;
|
||||
const double _kPadH = 18;
|
||||
|
||||
/// 머리 오른쪽의 작은 연주황 버튼 — "+ 명함 만들기" 등
|
||||
/// 머리 오른쪽의 작은 연주황 버튼 — "+ 명함 만들기", "+ 아이 등록하기", "편집"
|
||||
class RupSoftPillButton extends StatelessWidget {
|
||||
const RupSoftPillButton({super.key, required this.label, required this.onPressed, this.showPlus = true});
|
||||
const RupSoftPillButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.showPlus = true,
|
||||
this.background = AppColors.surface2,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final bool showPlus;
|
||||
|
||||
/// 머리의 "+" 버튼은 더 옅은 바탕, 프로필 "편집"은 기본 .rup-btn--soft 바탕(primary50)
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Pressable(
|
||||
@@ -47,7 +56,7 @@ class RupSoftPillButton extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14, vertical: showPlus ? AppSpacing.sm : 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface2,
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
border: Border.all(color: AppColors.primary100, width: 1.5),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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/core/theme/app_shadows.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/rup_button.dart';
|
||||
|
||||
const double _kDialogMaxWidth = 300;
|
||||
@@ -24,6 +25,9 @@ Future<bool> showRupConfirm(
|
||||
String cancelLabel = '취소',
|
||||
bool destructive = false,
|
||||
RupConfirmLayout layout = RupConfirmLayout.stacked,
|
||||
RupIconName? icon,
|
||||
Color iconColor = AppColors.ink2,
|
||||
Color? iconBackground,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -51,6 +55,20 @@ Future<bool> showRupConfirm(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (row) const SizedBox(height: AppSpacing.xs),
|
||||
if (icon != null)
|
||||
Center(
|
||||
child: Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: iconBackground ?? iconColor.withValues(alpha: .13),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.lg),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: RupIcon(icon, size: 26, stroke: 2, color: iconColor),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -84,7 +84,10 @@ class _RupSheetBody extends StatelessWidget {
|
||||
child: Text(
|
||||
title!,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
fontSize: 18,
|
||||
height: AppTypography.displayNormalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(child: child),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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/core/theme/app_typography.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
|
||||
const double _kTitleLinkPad = 2;
|
||||
|
||||
/// 섹션 제목 줄 — 왼쪽 제목, 오른쪽 "더보기 ›" 같은 보조 링크나 설명 (시안 .rup-section-title)
|
||||
class SectionTitle extends StatelessWidget {
|
||||
const SectionTitle({
|
||||
@@ -32,7 +35,8 @@ class SectionTitle extends StatelessWidget {
|
||||
color: AppColors.muted,
|
||||
height: 1.2,
|
||||
)
|
||||
: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600);
|
||||
// 줄 높이를 지정하지 않으면 본문 기본값(1.6)을 물려받아 시안 h2(글자 높이 그대로)보다 줄이 커진다
|
||||
: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight);
|
||||
final trailingStyle = textTheme.bodySmall!.copyWith(
|
||||
fontSize: 13,
|
||||
color: AppColors.muted,
|
||||
@@ -53,7 +57,8 @@ class SectionTitle extends StatelessWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTrailingTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
// 터치 여백은 제목 줄 높이를 넘지 않는 만큼만 — 넘치면 줄이 시안보다 커진다
|
||||
padding: EdgeInsets.symmetric(vertical: sub ? AppSpacing.xs : _kTitleLinkPad),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tma_app/core/constants/app_sizes.dart';
|
||||
|
||||
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
|
||||
const double _kBottomGap = 30;
|
||||
|
||||
/// 탭바를 그대로 둔 채 탭 안에서 넘어가는 하위 화면의 틀 (나의 반려동물·설정 등).
|
||||
/// 자식은 스스로 좌우 여백을 둔다 — 구분 띠([DividerBand])는 화면 끝까지 닿아야 하므로.
|
||||
class TabSubPage extends StatelessWidget {
|
||||
const TabSubPage({super.key, required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: AppSpacing.xs,
|
||||
// 탭바 높이는 바깥 셸(extendBody)이 padding.bottom 에 더해 준다
|
||||
bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap,
|
||||
),
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 화면 좌우 기본 여백(20)
|
||||
class ScreenPadding extends StatelessWidget {
|
||||
const ScreenPadding({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), child: child);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
Map<String, Object> _withPet() => {'pets.v1': jsonEncode([samplePet().toJson()])};
|
||||
|
||||
Future<void> _openMyPage(WidgetTester tester) async {
|
||||
await tester.tap(find.bySemanticsLabel('마이페이지'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> _openSettings(WidgetTester tester) async {
|
||||
await _openMyPage(tester);
|
||||
await tester.tap(find.bySemanticsLabel('설정'));
|
||||
await settle(tester);
|
||||
expect(find.byType(SettingsPage), findsOneWidget);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('홈의 "아이 상세보기"는 나의 반려동물 목록으로, 아이를 누르면 반려동물 정보로 간다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _withPet());
|
||||
|
||||
await tester.tap(find.text('아이 상세보기'));
|
||||
await settle(tester);
|
||||
expect(find.byType(MyPetsPage), findsOneWidget);
|
||||
expect(find.text('1마리와 함께해요'), findsOneWidget);
|
||||
expect(find.text('강아지 · 말티즈'), findsOneWidget);
|
||||
expect(find.text('3.2kg'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('콩이'));
|
||||
await settle(tester);
|
||||
expect(find.byType(PetInfoPage), findsOneWidget);
|
||||
expect(find.text('콩이의 상세 정보'), findsOneWidget);
|
||||
expect(find.text('2021.09.14'), findsOneWidget);
|
||||
expect(find.text('남아 · 중성화 완료'), findsOneWidget);
|
||||
// 혈액형·마이크로칩·담당 병원을 입력하지 않았다
|
||||
expect(find.text('미등록'), findsNWidgets(3));
|
||||
// 알레르기·보유 질환·염려 질환이 비어 있다
|
||||
expect(find.text('없음'), findsNWidgets(3));
|
||||
expect(find.text('명함 만들기'), findsOneWidget);
|
||||
|
||||
// 연필 → 수정 화면, 뒤로 → 정보 화면
|
||||
await tester.tap(find.bySemanticsLabel('콩이 정보 수정'));
|
||||
await settle(tester);
|
||||
expect(find.byType(PetRegisterPage), findsOneWidget);
|
||||
expect(find.text('반려동물 정보 수정'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('마이페이지는 로그인 계정과 아이 수·명함 수를 보여 준다', (tester) async {
|
||||
await launchSignedIn(tester, extra: _withPet());
|
||||
await _openMyPage(tester);
|
||||
|
||||
expect(find.text('카카오 계정으로 로그인했어요'), findsOneWidget);
|
||||
expect(find.text('1마리'), findsOneWidget);
|
||||
expect(find.text('0장'), findsOneWidget);
|
||||
expect(find.text('카메라 접근 권한'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('알림 스위치를 바꾸면 저장되고 안내가 뜬다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester);
|
||||
await _openMyPage(tester);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('혜택 및 이벤트 알림'));
|
||||
await settle(tester);
|
||||
expect(prefs.getBool('settings.notiEvent'), isTrue);
|
||||
expect(find.text('혜택·이벤트 알림을 켰어요'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('프로필 편집에서 닉네임을 바꾸면 홈 인사말도 바뀐다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester);
|
||||
await _openMyPage(tester);
|
||||
|
||||
await tester.tap(find.text('편집'));
|
||||
await settle(tester);
|
||||
expect(find.text('프로필 편집'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), '나영');
|
||||
await tester.tap(find.text('수정 완료'));
|
||||
await settle(tester);
|
||||
expect(prefs.getString('profile.nickname'), '나영');
|
||||
expect(find.text('나영'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('홈'));
|
||||
await settle(tester);
|
||||
expect(find.text('안녕하세요, 나영님'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('설정에서 약관 문서를 열 수 있다', (tester) async {
|
||||
await launchSignedIn(tester);
|
||||
await _openSettings(tester);
|
||||
|
||||
await tester.tap(find.text('이용약관'));
|
||||
await settle(tester);
|
||||
expect(find.text('제1조 (목적)'), findsOneWidget);
|
||||
expect(find.text('2026년 1월 1일 시행'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('로그아웃을 확인하면 로그인 화면으로 가고 기록은 남는다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester, extra: _withPet());
|
||||
await _openSettings(tester);
|
||||
|
||||
await tester.tap(find.text('로그아웃'));
|
||||
await settle(tester);
|
||||
expect(find.text('로그아웃할까요?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('로그아웃').last);
|
||||
await settle(tester);
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(prefs.getString('pets.v1'), isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('회원탈퇴를 확인하면 기기의 기록을 지우고 인트로로 돌아간다', (tester) async {
|
||||
final prefs = await launchSignedIn(tester, extra: _withPet());
|
||||
await _openSettings(tester);
|
||||
|
||||
await tester.tap(find.text('회원탈퇴'));
|
||||
await settle(tester);
|
||||
expect(find.text('정말 탈퇴할까요?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('탈퇴하기'));
|
||||
await settle(tester);
|
||||
expect(find.byType(IntroPage), findsOneWidget);
|
||||
expect(prefs.getString('pets.v1'), isNull);
|
||||
expect(prefs.getString('auth.demoSession'), isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user