From d71855d5ce8bf49c33a4f49e2741a95249417264 Mon Sep 17 00:00:00 2001 From: seizeh Date: Tue, 21 Jul 2026 14:40:40 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20dart=20format=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9(37=EA=B0=9C)=20+=20CI=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=C2=B7=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 게이트가 명목상이라는 지적 해소: - dart format 미적용 파일 37개 일괄 포맷(순수 포맷 변경, 로직 무변화 — 테스트 104건 통과로 확인). post_create_screen.dart 는 진행 중 작업 (WIP·PR #143)과 겹쳐 일시 제외, 머지 후 포맷 예정 - CI 포맷 게이트: lib/test 의 tracked dart 파일 전체(위 1개 제외) - CI 커버리지 하한 게이트: 10% 미만 실패(현재 12.5%, 오르는 만큼 상향) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 17 +- lib/screen/activity_screens.dart | 20 +- lib/screen/admin/admin_business_screen.dart | 11 +- lib/screen/auth/signup_phone_screen.dart | 3 +- lib/screen/business_register_screen.dart | 146 ++++----- lib/screen/main_screen.dart | 6 +- lib/screen/post_detail_screen.dart | 10 +- lib/screen/profile_edit_screen.dart | 300 +++++++++--------- lib/screen/review_detail_screen.dart | 25 +- lib/screen/tabs/map_tab.dart | 16 +- lib/screen/tabs/my_info_tab.dart | 12 +- lib/screen/user_profile_screen.dart | 35 +- lib/services/admin_repository.dart | 28 +- lib/services/business_repository.dart | 10 +- lib/services/chat_repository.dart | 10 +- lib/services/juso_service.dart | 6 +- lib/services/pet_repository.dart | 4 +- lib/services/profile_repository.dart | 5 +- lib/services/push_service.dart | 6 +- lib/services/session.dart | 8 +- lib/services/storage_service.dart | 25 +- lib/widgets/facility_sheet.dart | 225 +++++++------ lib/widgets/review_cards.dart | 3 +- lib/widgets/user_tile.dart | 4 +- test/address_sheet_repro_test.dart | 5 +- test/facility_sheet_test.dart | 82 ++--- test/helpers/fake_session.dart | 40 +-- test/region_posts_sheet_test.dart | 10 +- test/review_detail_test.dart | 52 +-- test/services/auth_flow_test.dart | 23 +- test/services/chat_model_test.dart | 11 +- test/services/chat_repository_test.dart | 13 +- test/services/community_model_test.dart | 23 +- test/services/community_repository_test.dart | 29 +- .../notification_repository_test.dart | 33 +- test/services/session_manager_test.dart | 83 ++--- test/services/session_refresh_test.dart | 41 ++- test/services/social_repository_test.dart | 41 ++- 38 files changed, 798 insertions(+), 623 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdae95..546da9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,14 +21,25 @@ jobs: - run: flutter pub get + # 포맷 게이트. post_create_screen.dart 만 일시 제외 — 진행 중 작업(WIP·PR #143)과 + # 겹쳐 있어 해당 작업 머지 후 포맷하고 제외를 풀 것. + - name: 포맷 검사 + run: | + git ls-files | grep -E '^(lib|test)/.*\.dart$' \ + | grep -v '^lib/screen/post_create_screen\.dart$' \ + | xargs dart format --output=none --set-exit-if-changed + # info 수준 린트는 실패로 치지 않는다(warning/error 만 게이트). - run: flutter analyze --no-fatal-infos - run: flutter test --coverage - # 라인 커버리지 요약(아직 게이트 아님 — 측정·가시화 단계). - - name: 커버리지 요약 + # 커버리지 요약 + 하한 게이트(10% 미만이면 실패). 커버리지가 오르는 만큼 + # 하한도 따라 올릴 것(현재 12%대). + - name: 커버리지 요약·게이트(≥10%) + shell: bash run: | awk -F'[:,]' '/^DA:/{t++; if($3>0) h++} - END{printf "라인 커버리지 %.1f%% (%d/%d)\n", h*100/t, h, t}' \ + END{pct=h*100/t; printf "라인 커버리지 %.1f%% (%d/%d)\n", pct, h, t; + exit (pct>=10)?0:1}' \ coverage/lcov.info | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/lib/screen/activity_screens.dart b/lib/screen/activity_screens.dart index f0dc064..0725b09 100644 --- a/lib/screen/activity_screens.dart +++ b/lib/screen/activity_screens.dart @@ -464,7 +464,15 @@ class _MyAppointmentsScreenState extends State { ), Row( children: [ - for (final (i, w) in const ['일', '월', '화', '수', '목', '금', '토'].indexed) + for (final (i, w) in const [ + '일', + '월', + '화', + '수', + '목', + '금', + '토', + ].indexed) Expanded( child: Center( child: Text( @@ -486,8 +494,14 @@ class _MyAppointmentsScreenState extends State { Row( children: [ for (var dow = 0; dow < 7; dow++) - Expanded(child: _dayCell(week * 7 + dow - firstWeekday + 1, - daysInMonth, counts, todayKey)), + Expanded( + child: _dayCell( + week * 7 + dow - firstWeekday + 1, + daysInMonth, + counts, + todayKey, + ), + ), ], ), ], diff --git a/lib/screen/admin/admin_business_screen.dart b/lib/screen/admin/admin_business_screen.dart index 61f95b2..e0db97c 100644 --- a/lib/screen/admin/admin_business_screen.dart +++ b/lib/screen/admin/admin_business_screen.dart @@ -234,7 +234,8 @@ class _DetailSheetState extends State<_DetailSheet> { _row('주소(도로명)', a.businessAddress), if ((a.businessAddressJibun ?? '').isNotEmpty) _row('주소(지번)', a.businessAddressJibun!), - if ((a.businessPhone ?? '').isNotEmpty) _row('업장 전화', a.businessPhone!), + if ((a.businessPhone ?? '').isNotEmpty) + _row('업장 전화', a.businessPhone!), if ((a.representativeName ?? '').isNotEmpty) _row('대표자', a.representativeName!), _row('이메일', a.contactEmail), @@ -273,7 +274,9 @@ class _DetailSheetState extends State<_DetailSheet> { onPressed: () => _openDoc(a.licenseImagePath), icon: const Icon(Icons.description_outlined), label: const Text('사업자등록증 열람'), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(46)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + ), ), if (a.extraDocPath != null) ...[ const SizedBox(height: 8), @@ -281,7 +284,9 @@ class _DetailSheetState extends State<_DetailSheet> { onPressed: () => _openDoc(a.extraDocPath!), icon: const Icon(Icons.attach_file), label: const Text('추가 서류 열람 (영업 등록증 등)'), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(46)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + ), ), ], const SizedBox(height: 20), diff --git a/lib/screen/auth/signup_phone_screen.dart b/lib/screen/auth/signup_phone_screen.dart index 2346d45..529c669 100644 --- a/lib/screen/auth/signup_phone_screen.dart +++ b/lib/screen/auth/signup_phone_screen.dart @@ -805,8 +805,7 @@ class _SignupPhoneScreenState extends State { .where((p) => p.length >= 10 && p != myPhone) .toSet() .toList(); - final petDraft = - (!_isBusiness && _petNameCtrl.text.trim().isNotEmpty) + final petDraft = (!_isBusiness && _petNameCtrl.text.trim().isNotEmpty) ? PetDraft( name: _petNameCtrl.text.trim(), speciesKind: _petKind, diff --git a/lib/screen/business_register_screen.dart b/lib/screen/business_register_screen.dart index 37e2ca7..fb5cc8c 100644 --- a/lib/screen/business_register_screen.dart +++ b/lib/screen/business_register_screen.dart @@ -217,9 +217,7 @@ class _BusinessRegisterScreenState extends State { TextField( controller: _nameCtrl, onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - hintText: '사업자등록증에 기재된 상호', - ), + decoration: const InputDecoration(hintText: '사업자등록증에 기재된 상호'), ), const SizedBox(height: 8), _foldToggle( @@ -396,10 +394,7 @@ class _BusinessRegisterScreenState extends State { const SizedBox(height: 4), Text( '사유: ${_mine?.rejectedReason ?? '-'}', - style: TextStyle( - fontSize: 13, - color: context.colors.textSecondary, - ), + style: TextStyle(fontSize: 13, color: context.colors.textSecondary), ), ], ), @@ -516,9 +511,10 @@ class _BusinessRegisterScreenState extends State { : switch (r.statusCode) { '02' => '휴업 상태의 사업자번호는 등록할 수 없어요', '03' => '폐업 상태의 사업자번호는 등록할 수 없어요', - _ => r.error == 'nts_unavailable' || r.error == 'network' - ? '국세청 확인에 실패했어요. 잠시 후 다시 시도해주세요' - : '국세청에 등록되지 않은 사업자번호예요', + _ => + r.error == 'nts_unavailable' || r.error == 'network' + ? '국세청 확인에 실패했어요. 잠시 후 다시 시도해주세요' + : '국세청에 등록되지 않은 사업자번호예요', }; }); } @@ -530,9 +526,7 @@ class _BusinessRegisterScreenState extends State { return TextField( controller: _manualAddrCtrl, onChanged: (_) => setState(() {}), - decoration: const InputDecoration( - hintText: '사업자등록증의 사업장 소재지 (도로명 주소)', - ), + decoration: const InputDecoration(hintText: '사업자등록증의 사업장 소재지 (도로명 주소)'), ); } return InkWell( @@ -801,49 +795,52 @@ class _StatusView extends StatelessWidget { return ListView( padding: const EdgeInsets.all(24), children: [ - const SizedBox(height: 8), - Icon(icon, size: 56, color: context.colors.primary), - const SizedBox(height: 16), - Text( - title, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), + const SizedBox(height: 8), + Icon(icon, size: 56, color: context.colors.primary), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: context.colors.textPrimary, ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13.5, - height: 1.5, - color: context.colors.textSecondary, - ), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.5, + height: 1.5, + color: context.colors.textSecondary, ), - const SizedBox(height: 28), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: context.colors.border, width: 0.5), - ), - child: Column( - children: [ - _row(context, '상호', profile.businessName), - if ((profile.storefrontName ?? '').isNotEmpty) - _row(context, '사업장명', profile.storefrontName!), - _row(context, '업종', - businessCategoryLabel(profile.declaredCategory)), - _row(context, '사업자번호', _maskBno(profile.businessRegNo)), - _row(context, '주소', profile.businessAddress), - _row(context, '이메일', profile.contactEmail), - ], - ), + ), + const SizedBox(height: 28), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: context.colors.border, width: 0.5), + ), + child: Column( + children: [ + _row(context, '상호', profile.businessName), + if ((profile.storefrontName ?? '').isNotEmpty) + _row(context, '사업장명', profile.storefrontName!), + _row( + context, + '업종', + businessCategoryLabel(profile.declaredCategory), + ), + _row(context, '사업자번호', _maskBno(profile.businessRegNo)), + _row(context, '주소', profile.businessAddress), + _row(context, '이메일', profile.contactEmail), + ], ), + ), ], ); } @@ -862,19 +859,13 @@ class _StatusView extends StatelessWidget { width: 76, child: Text( k, - style: TextStyle( - fontSize: 13, - color: context.colors.textTertiary, - ), + style: TextStyle(fontSize: 13, color: context.colors.textTertiary), ), ), Expanded( child: Text( v, - style: TextStyle( - fontSize: 13.5, - color: context.colors.textPrimary, - ), + style: TextStyle(fontSize: 13.5, color: context.colors.textPrimary), ), ), ], @@ -905,7 +896,10 @@ class _PhotoAlignSheetState extends State<_PhotoAlignSheet> { Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB( - 20, 20, 20, 20 + MediaQuery.of(context).padding.bottom, + 20, + 20, + 20, + 20 + MediaQuery.of(context).padding.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -1082,9 +1076,7 @@ class AddressSearchSheetState extends State { autofocus: true, textInputAction: TextInputAction.search, onSubmitted: (_) => _search(), - decoration: const InputDecoration( - hintText: '도로명, 건물명 또는 지번', - ), + decoration: const InputDecoration(hintText: '도로명, 건물명 또는 지번'), ), ), const SizedBox(width: 8), @@ -1151,7 +1143,6 @@ class AddressSearchSheetState extends State { } } - /// 승인 업체 관리 패널 — 업체 정보 요약·수정, 대표 사진 설정/위치 조절/제거. /// BusinessRegisterScreen(승인 상태)과 업체 모드의 내정보(업체 관리) 최상단에서 /// 공용 — "프로필 한 번 탭으로 수정 도달" 요구의 구현체. @@ -1318,7 +1309,9 @@ class _BusinessManagePanelState extends State { onPressed: _openInfoEdit, icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('업체 정보 수정'), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), ), const SizedBox(height: 20), // 계정 단위 활동(하트·친구)은 두 얼굴 공용 — 업체 모드에서도 조회 가능하게. @@ -1338,13 +1331,14 @@ class _BusinessManagePanelState extends State { onPressed: () => Navigator.push( context, AppPageRoute( - builder: (_) => - const MyPostsScreen(mode: PostListMode.hearted), + builder: (_) => const MyPostsScreen(mode: PostListMode.hearted), ), ), icon: const Icon(Icons.favorite_border, size: 18), label: const Text('하트한 게시글'), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), ), const SizedBox(height: 8), OutlinedButton.icon( @@ -1354,7 +1348,9 @@ class _BusinessManagePanelState extends State { ), icon: const Icon(Icons.group_outlined, size: 18), label: const Text('내 친구 (Pawing · Pawmate)'), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(48)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), ), ], ); @@ -1450,7 +1446,8 @@ class _BusinessManagePanelState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (_) => _PhotoAlignSheet(image: image, initial: initial, name: name), + builder: (_) => + _PhotoAlignSheet(image: image, initial: initial, name: name), ); } @@ -1472,7 +1469,10 @@ class _BusinessManagePanelState extends State { ), builder: (sheetCtx) => Padding( padding: EdgeInsets.fromLTRB( - 20, 20, 20, 20 + MediaQuery.of(sheetCtx).viewInsets.bottom, + 20, + 20, + 20, + 20 + MediaQuery.of(sheetCtx).viewInsets.bottom, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screen/main_screen.dart b/lib/screen/main_screen.dart index 3c3c0df..cfc2605 100644 --- a/lib/screen/main_screen.dart +++ b/lib/screen/main_screen.dart @@ -19,7 +19,11 @@ class MainScreen extends StatefulWidget { /// null 로 리셋한다. 딥링크가 상세를 얹기 전에 "뒤로 갔을 때 보일 탭"을 맞출 때 사용. static final ValueNotifier tabRequest = ValueNotifier(null); - static const tabMap = 0, tabSearch = 1, tabCommunity = 2, tabChat = 3, tabMyInfo = 4; + static const tabMap = 0, + tabSearch = 1, + tabCommunity = 2, + tabChat = 3, + tabMyInfo = 4; @override State createState() => _MainScreenState(); diff --git a/lib/screen/post_detail_screen.dart b/lib/screen/post_detail_screen.dart index 647d5b4..4463ecc 100644 --- a/lib/screen/post_detail_screen.dart +++ b/lib/screen/post_detail_screen.dart @@ -84,8 +84,7 @@ class _PostDetailScreenState extends State { bool _businessMode = false; /// 매칭(지원→약속) 없는 게시글 — 자유글·업체 소식. 지원 UI 를 띄우지 않는다. - bool get _isFreePost => - _post.category == 'free' || _post.category == 'news'; + bool get _isFreePost => _post.category == 'free' || _post.category == 'news'; bool get _isMyPost => _post.userId == SessionManager.instance.user?.id; @override @@ -154,7 +153,8 @@ class _PostDetailScreenState extends State { if (!_guard('팔로우는 로그인 후 할 수 있어요')) return; final now = DateTime.now(); if (_lastFollowToggle != null && - now.difference(_lastFollowToggle!) < const Duration(milliseconds: 700)) { + now.difference(_lastFollowToggle!) < + const Duration(milliseconds: 700)) { return; } _lastFollowToggle = now; @@ -259,7 +259,9 @@ class _PostDetailScreenState extends State { context: context, builder: (dCtx) => AlertDialog( title: const Text('업체 모드에서는 지원할 수 없어요'), - content: const Text('산책·돌봄 매칭은 일반 모드에서 이용할 수 있어요.\n일반 모드로 전환하고 지원할까요?'), + content: const Text( + '산책·돌봄 매칭은 일반 모드에서 이용할 수 있어요.\n일반 모드로 전환하고 지원할까요?', + ), actions: [ TextButton( onPressed: () => Navigator.pop(dCtx, false), diff --git a/lib/screen/profile_edit_screen.dart b/lib/screen/profile_edit_screen.dart index 4babe47..05c3084 100644 --- a/lib/screen/profile_edit_screen.dart +++ b/lib/screen/profile_edit_screen.dart @@ -222,167 +222,175 @@ class _ProfileEditScreenState extends State { child: _isBizMode ? _businessBody() : Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( children: [ - GestureDetector( - onTap: _uploading ? null : _pickImage, - child: Container( - width: 110, - height: 110, - decoration: BoxDecoration( - color: context.colors.primarySoft, - shape: BoxShape.circle, - image: _imageUrl != null - ? DecorationImage( - image: NetworkImage(_imageUrl!), - fit: BoxFit.cover, - ) - : null, - ), - child: _uploading - ? const Center(child: CircularProgressIndicator()) - : (_imageUrl == null - ? Center( - child: Text( - initial.characters.first, - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.w700, - color: context.colors.primaryDark, - ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + GestureDetector( + onTap: _uploading ? null : _pickImage, + child: Container( + width: 110, + height: 110, + decoration: BoxDecoration( + color: context.colors.primarySoft, + shape: BoxShape.circle, + image: _imageUrl != null + ? DecorationImage( + image: NetworkImage(_imageUrl!), + fit: BoxFit.cover, + ) + : null, + ), + child: _uploading + ? const Center( + child: CircularProgressIndicator(), ) - : null), - ), - ), - const SizedBox(height: 8), - TextButton.icon( - onPressed: _uploading ? null : _pickImage, - icon: const Icon(Icons.camera_alt_outlined, size: 16), - label: const Text('사진 변경'), - ), - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: Text( - '닉네임', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), - ), - ), - const SizedBox(height: 8), - TextField( - controller: _nickCtrl, - onChanged: (_) => setState(() {}), - decoration: const InputDecoration(hintText: '닉네임'), - ), - const SizedBox(height: 24), - Align( - alignment: Alignment.centerLeft, - child: Text( - '활동 지역', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: context.colors.textPrimary, - ), - ), - ), - const SizedBox(height: 8), - InkWell( - onTap: _verifyRegion, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 14, - ), - decoration: BoxDecoration( - border: Border.all(color: context.colors.border), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.location_on_outlined, - size: 20, - color: context.colors.primaryDark, + : (_imageUrl == null + ? Center( + child: Text( + initial.characters.first, + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.w700, + color: + context.colors.primaryDark, + ), + ), + ) + : null), ), - const SizedBox(width: 12), - Expanded( - child: Text( - _verified && _regionName != null - ? _regionName! - : '지역 미인증', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: _verified - ? context.colors.textPrimary - : context.colors.textTertiary, - ), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _uploading ? null : _pickImage, + icon: const Icon( + Icons.camera_alt_outlined, + size: 16, + ), + label: const Text('사진 변경'), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Text( + '닉네임', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: context.colors.textPrimary, ), ), - Text( - _verified ? '재인증' : 'GPS로 인증', + ), + const SizedBox(height: 8), + TextField( + controller: _nickCtrl, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(hintText: '닉네임'), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerLeft, + child: Text( + '활동 지역', style: TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w700, - color: context.colors.primaryDark, + color: context.colors.textPrimary, ), ), - Icon( - Icons.chevron_right, - size: 18, - color: context.colors.textTertiary, + ), + const SizedBox(height: 8), + InkWell( + onTap: _verifyRegion, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + decoration: BoxDecoration( + border: Border.all( + color: context.colors.border, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 20, + color: context.colors.primaryDark, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _verified && _regionName != null + ? _regionName! + : '지역 미인증', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: _verified + ? context.colors.textPrimary + : context.colors.textTertiary, + ), + ), + ), + Text( + _verified ? '재인증' : 'GPS로 인증', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: context.colors.primaryDark, + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: context.colors.textTertiary, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + '활동 지역은 현재 위치(GPS)로만 인증돼요.', + style: TextStyle( + fontSize: 12, + color: context.colors.textTertiary, + ), ), - ], - ), + ), + ], ), ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: Text( - '활동 지역은 현재 위치(GPS)로만 인증돼요.', - style: TextStyle( - fontSize: 12, - color: context.colors.textTertiary, - ), + // 내 활동/활동 범위/관심/설정 — 내정보 탭에서 이동해온 섹션들. + if (widget.profile != null) ...[ + const SizedBox(height: 28), + _ActivitySection( + profile: widget.profile!, + pendingInvites: widget.pendingInvites, ), - ), + const SizedBox(height: 12), + _ActivityRangeSection(profile: widget.profile!), + const SizedBox(height: 12), + _InterestSection(profile: widget.profile!), + const SizedBox(height: 12), + // 개인 화면의 업체 섹션은 등록/전환만 — 업체 정보·사진 관리는 + // 업체 모드(업체 관리 화면) 전용(0026, 사진 결합 혼란 제거). + _BusinessSection( + isBizMode: false, + onModeChanged: (m) => setState(() => _mode = m), + ), + const SizedBox(height: 12), + _SettingsSection(profile: widget.profile!), + ], ], ), - ), - // 내 활동/활동 범위/관심/설정 — 내정보 탭에서 이동해온 섹션들. - if (widget.profile != null) ...[ - const SizedBox(height: 28), - _ActivitySection( - profile: widget.profile!, - pendingInvites: widget.pendingInvites, - ), - const SizedBox(height: 12), - _ActivityRangeSection(profile: widget.profile!), - const SizedBox(height: 12), - _InterestSection(profile: widget.profile!), - const SizedBox(height: 12), - // 개인 화면의 업체 섹션은 등록/전환만 — 업체 정보·사진 관리는 - // 업체 모드(업체 관리 화면) 전용(0026, 사진 결합 혼란 제거). - _BusinessSection( - isBizMode: false, - onModeChanged: (m) => setState(() => _mode = m), - ), - const SizedBox(height: 12), - _SettingsSection(profile: widget.profile!), - ], - ], - ), ), ), ); diff --git a/lib/screen/review_detail_screen.dart b/lib/screen/review_detail_screen.dart index 1deecc2..e983ad1 100644 --- a/lib/screen/review_detail_screen.dart +++ b/lib/screen/review_detail_screen.dart @@ -61,7 +61,9 @@ class _ReviewDetailScreenState extends State { super.initState(); if (_reviewId != null) _loadComments(); if (widget.fromDeepLink && _reviewId != null) { - WidgetsBinding.instance.addPostFrameCallback((_) => _maybeSuggestSwitch()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _maybeSuggestSwitch(), + ); } } @@ -95,11 +97,7 @@ class _ReviewDetailScreenState extends State { final res = await BusinessRepository.instance.switchMode('business'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - res == 'business' ? '업체 모드로 전환했어요' : '전환에 실패했어요', - ), - ), + SnackBar(content: Text(res == 'business' ? '업체 모드로 전환했어요' : '전환에 실패했어요')), ); } @@ -138,9 +136,9 @@ class _ReviewDetailScreenState extends State { await _loadComments(); } catch (_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('댓글 작성에 실패했어요')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('댓글 작성에 실패했어요'))); } finally { if (mounted) setState(() => _sending = false); } @@ -170,9 +168,9 @@ class _ReviewDetailScreenState extends State { await _loadComments(); } catch (_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('삭제에 실패했어요')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('삭제에 실패했어요'))); } } @@ -366,7 +364,8 @@ class _ReviewDetailScreenState extends State { /// 본문 정보 위젯들 — 게시글 상세의 칩→제목→작성자→본문 순서를 후기 문법으로: /// 방문 차수 칩 → 별점(제목 자리) → 작성자·날짜 → 본문 → 나머지 사진. List _infoChildren(ReviewCardData r, {required bool contentInHero}) { - final showContent = (r.content ?? '').isNotEmpty && + final showContent = + (r.content ?? '').isNotEmpty && (!contentInHero || !_heroHoldsFullContent); return [ if (r.visitNo != null || r.isMine) diff --git a/lib/screen/tabs/map_tab.dart b/lib/screen/tabs/map_tab.dart index 9fb167c..550cc1f 100644 --- a/lib/screen/tabs/map_tab.dart +++ b/lib/screen/tabs/map_tab.dart @@ -532,7 +532,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } /// 카테고리 마커 아이콘(캐시). PNG 를 흰 원형 핀에 합성해 일관/또렷하게. - Future _iconFor(String category, {bool verified = false}) async { + Future _iconFor( + String category, { + bool verified = false, + }) async { final key = verified ? '$category|v' : category; // 인증 변형은 별도 캐시 if (_catIcons.containsKey(key)) return _catIcons[key]; // 모드별 아이콘 색 — await 전에 캡처(빌드 컨텍스트 안전). @@ -540,7 +543,12 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final dark = context.isDark; NOverlayImage? out; try { - out = await _renderMarkerIcon(category, color, verified: verified, dark: dark); + out = await _renderMarkerIcon( + category, + color, + verified: verified, + dark: dark, + ); } catch (_) { out = null; } @@ -1089,7 +1097,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ? _suggestions // 겸업 업체(형제 통합)는 여러 카테고리를 가지므로 '포함'으로 판별 — // 미용+분양 업체가 미용·분양 어느 칩에서도 보이게(검색 dedupe 후). - : _suggestions.where((f) => f.categories.contains(_suggestCat)).toList(); + : _suggestions + .where((f) => f.categories.contains(_suggestCat)) + .toList(); return Container( margin: const EdgeInsets.only(top: 6), constraints: const BoxConstraints(maxHeight: 280), diff --git a/lib/screen/tabs/my_info_tab.dart b/lib/screen/tabs/my_info_tab.dart index e61340d..e62df9a 100644 --- a/lib/screen/tabs/my_info_tab.dart +++ b/lib/screen/tabs/my_info_tab.dart @@ -306,8 +306,7 @@ class _MyInfoTabState extends State double? bizAvg; if (p.activeMode == 'business') { try { - final rs = await BusinessRepository.instance - .fetchMyFacilityReviews(); + final rs = await BusinessRepository.instance.fetchMyFacilityReviews(); bizCount = rs.length; bizAvg = rs.isEmpty ? null @@ -505,8 +504,7 @@ class _BusinessReviewsSectionState extends State<_BusinessReviewsSection> { final reviews = _reviews; final avg = (reviews == null || reviews.isEmpty) ? null - : reviews.map((r) => r.rating).reduce((a, b) => a + b) / - reviews.length; + : reviews.map((r) => r.rating).reduce((a, b) => a + b) / reviews.length; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -525,7 +523,11 @@ class _BusinessReviewsSectionState extends State<_BusinessReviewsSection> { if (reviews != null && reviews.isNotEmpty) ...[ const SizedBox(width: 8), // 별점 색은 시설 후기 화면과 동일 컨벤션. - const Icon(Icons.star_rounded, size: 18, color: Color(0xFFFFB300)), + const Icon( + Icons.star_rounded, + size: 18, + color: Color(0xFFFFB300), + ), Text( '${avg!.toStringAsFixed(1)} · ${reviews.length}', style: TextStyle( diff --git a/lib/screen/user_profile_screen.dart b/lib/screen/user_profile_screen.dart index 821e4e7..0452ca5 100644 --- a/lib/screen/user_profile_screen.dart +++ b/lib/screen/user_profile_screen.dart @@ -186,7 +186,8 @@ class _UserProfileScreenState extends State { // (알림 자체는 서버가 90초 유예 후 발송하므로 그 안의 취소는 조용하다.) final now = DateTime.now(); if (_lastFollowToggle != null && - now.difference(_lastFollowToggle!) < const Duration(milliseconds: 700)) { + now.difference(_lastFollowToggle!) < + const Duration(milliseconds: 700)) { return; } _lastFollowToggle = now; @@ -757,9 +758,7 @@ class _UserProfileScreenState extends State { ) : Row( children: [ - Expanded( - child: _statCol('후기', _bizReviews.length), - ), + Expanded(child: _statCol('후기', _bizReviews.length)), _statDivider(), Expanded( child: _statText( @@ -984,9 +983,7 @@ class _UserProfileScreenState extends State { color: onTap == null ? context.colors.textPrimary : context.colors.primaryDark, - fontWeight: onTap == null - ? FontWeight.w400 - : FontWeight.w600, + fontWeight: onTap == null ? FontWeight.w400 : FontWeight.w600, ), ), ), @@ -1008,7 +1005,11 @@ class _UserProfileScreenState extends State { children: [ Row( children: [ - Icon(Icons.verified, size: 16, color: context.colors.primaryDark), + Icon( + Icons.verified, + size: 16, + color: context.colors.primaryDark, + ), const SizedBox(width: 5), Text( '사업자 인증을 완료한 업체예요', @@ -1022,16 +1023,17 @@ class _UserProfileScreenState extends State { ), const SizedBox(height: 10), if (p.businessCategory != null) - row(Icons.storefront_outlined, - businessCategoryLabel(p.businessCategory!)), + row( + Icons.storefront_outlined, + businessCategoryLabel(p.businessCategory!), + ), if ((p.businessAddress ?? '').isNotEmpty) row(Icons.place_outlined, p.businessAddress!), if ((p.businessPhone ?? '').isNotEmpty) row( Icons.call_outlined, formatKrPhone(p.businessPhone), - onTap: () => - launchUrl(Uri.parse('tel:${p.businessPhone!}')), + onTap: () => launchUrl(Uri.parse('tel:${p.businessPhone!}')), ), if ((p.businessHours ?? '').isNotEmpty) row(Icons.schedule_outlined, p.businessHours!), @@ -1045,8 +1047,7 @@ class _UserProfileScreenState extends State { /// 이 업체 프로필에서 후기를 쓸 수 있는가 — 본인(업주 계정, 모드 무관)은 제외. /// 서버(add_facility_review)도 own_facility 로 차단하는 불변식의 UX 버전. - bool get _canWriteBizReview => - !_isMe && _profile?.businessFacilityId != null; + bool get _canWriteBizReview => !_isMe && _profile?.businessFacilityId != null; /// 업체 프로필에서 바로 후기 작성 — 지도 상세와 동일한 작성 화면으로. Future _writeBizReview() async { @@ -1251,11 +1252,7 @@ class PostPhotoTile extends StatelessWidget { ), child: Row( children: [ - const Icon( - Icons.favorite, - size: 14, - color: Colors.white, - ), + const Icon(Icons.favorite, size: 14, color: Colors.white), const SizedBox(width: 3), Text( '${post.heartCount}', diff --git a/lib/services/admin_repository.dart b/lib/services/admin_repository.dart index e1b2a3e..54cf4bf 100644 --- a/lib/services/admin_repository.dart +++ b/lib/services/admin_repository.dart @@ -215,19 +215,18 @@ class AdminChatMessage { required this.createdAt, }); - factory AdminChatMessage.fromJson(Map j) => - AdminChatMessage( - id: j['id'] as String, - senderId: (j['sender_id'] ?? '') as String, - senderNickname: (j['sender_nickname'] ?? '알 수 없음') as String, - content: j['content'] as String?, - imageUrl: j['image_url'] as String?, - isDeleted: j['is_deleted'] == true, - deletedAt: j['deleted_at'] == null - ? null - : DateTime.parse(j['deleted_at'] as String).toLocal(), - createdAt: DateTime.parse(j['created_at'] as String).toLocal(), - ); + factory AdminChatMessage.fromJson(Map j) => AdminChatMessage( + id: j['id'] as String, + senderId: (j['sender_id'] ?? '') as String, + senderNickname: (j['sender_nickname'] ?? '알 수 없음') as String, + content: j['content'] as String?, + imageUrl: j['image_url'] as String?, + isDeleted: j['is_deleted'] == true, + deletedAt: j['deleted_at'] == null + ? null + : DateTime.parse(j['deleted_at'] as String).toLocal(), + createdAt: DateTime.parse(j['created_at'] as String).toLocal(), + ); } /// 신고 대상의 실제 내용 (게시글/댓글/회원/채팅메시지). [data] 는 kind 별 필드. @@ -505,7 +504,8 @@ class AdminBusinessApplication { extraDocPath: j['extra_doc_path'] as String?, matchedFacilityName: j['matched_facility_name'] as String?, matchScore: (j['match_score'] as num?)?.toInt(), - matchDetail: (j['match_detail'] as Map?)?.cast() ?? const {}, + matchDetail: + (j['match_detail'] as Map?)?.cast() ?? const {}, reviewTrack: (j['review_track'] ?? 'review') as String, autoApproved: j['auto_approved'] == true, status: (j['status'] ?? 'pending') as String, diff --git a/lib/services/business_repository.dart b/lib/services/business_repository.dart index 99fef5f..fb5528c 100644 --- a/lib/services/business_repository.dart +++ b/lib/services/business_repository.dart @@ -168,8 +168,9 @@ class BusinessRepository { authorNickname: (r['author_nickname'] as String?) ?? '알 수 없음', rating: (r['rating'] as num?)?.toInt() ?? 0, content: r['content'] as String?, - createdAt: - DateTime.tryParse(r['created_at'] as String? ?? '')?.toLocal(), + createdAt: DateTime.tryParse( + r['created_at'] as String? ?? '', + )?.toLocal(), photoUrls: [ for (final u in (r['photo_urls'] as List? ?? const [])) u as String, @@ -224,10 +225,7 @@ class BusinessRepository { /// 계정 전환 — business 는 승인(approved) 상태에서만 서버가 허용. Future switchMode(String mode) async { try { - final res = await _c.rpc( - 'switch_account_mode', - params: {'p_mode': mode}, - ); + final res = await _c.rpc('switch_account_mode', params: {'p_mode': mode}); return res as String?; } catch (_) { return null; diff --git a/lib/services/chat_repository.dart b/lib/services/chat_repository.dart index 7dd0d11..50ec7d5 100644 --- a/lib/services/chat_repository.dart +++ b/lib/services/chat_repository.dart @@ -28,10 +28,12 @@ class ChatRepository { String otherUserId, { String context = 'personal', }) async { - final roomId = await _c.rpc( - 'start_direct_chat', - params: {'p_other': otherUserId, 'p_context': context}, - ) as String; + final roomId = + await _c.rpc( + 'start_direct_chat', + params: {'p_other': otherUserId, 'p_context': context}, + ) + as String; final row = await _c .from('v_chat_rooms') .select() diff --git a/lib/services/juso_service.dart b/lib/services/juso_service.dart index 8b8e85a..9e0729f 100644 --- a/lib/services/juso_service.dart +++ b/lib/services/juso_service.dart @@ -24,8 +24,10 @@ class JusoService { Future> search(String keyword, {int page = 1}) async { if (!enabled || keyword.trim().length < 2) return const []; try { - final uri = Uri.parse('https://business.juso.go.kr/addrlink/addrLinkApi.do') - .replace( + final uri = + Uri.parse( + 'https://business.juso.go.kr/addrlink/addrLinkApi.do', + ).replace( queryParameters: { 'confmKey': _key, 'currentPage': '$page', diff --git a/lib/services/pet_repository.dart b/lib/services/pet_repository.dart index 7a6b15a..b918be2 100644 --- a/lib/services/pet_repository.dart +++ b/lib/services/pet_repository.dart @@ -244,7 +244,9 @@ class PetRepository { for (final p in (profs as List).cast>()) { nickById[p['id'] as String] = (p['nickname'] ?? '알 수 없음') as String; } - } catch (_) {/* 닉네임 조회 실패해도 보호자 목록은 반환 */} + } catch (_) { + /* 닉네임 조회 실패해도 보호자 목록은 반환 */ + } return list.map((r) { final userId = r['user_id'] as String; diff --git a/lib/services/profile_repository.dart b/lib/services/profile_repository.dart index d6d6784..b9a2fcb 100644 --- a/lib/services/profile_repository.dart +++ b/lib/services/profile_repository.dart @@ -212,10 +212,7 @@ class ProfileRepository { /// (대표 보호자 펫만 보이던 문제: 공동보호자 프로필에서 펫 누락 해결). Future> _fetchPublicPets(String userId) async { try { - final rows = await _c.rpc( - 'public_user_pets', - params: {'p_user': userId}, - ); + final rows = await _c.rpc('public_user_pets', params: {'p_user': userId}); return [ for (final p in (rows as List).cast>()) MockPet( diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart index bd51b38..d3dc393 100644 --- a/lib/services/push_service.dart +++ b/lib/services/push_service.dart @@ -137,6 +137,10 @@ class PushService { return (s == null || s.isEmpty) ? null : s; } - onOpen?.call((m['type'] as String?) ?? '', nn(m['resource_type']), nn(m['resource_id'])); + onOpen?.call( + (m['type'] as String?) ?? '', + nn(m['resource_type']), + nn(m['resource_id']), + ); } } diff --git a/lib/services/session.dart b/lib/services/session.dart index 53bbb18..87c6121 100644 --- a/lib/services/session.dart +++ b/lib/services/session.dart @@ -234,9 +234,11 @@ class SessionManager extends ChangeNotifier { try { final parts = jwt.split('.'); if (parts.length != 3) return null; - final payload = jsonDecode( - utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), - ) as Map; + final payload = + jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ) + as Map; final exp = payload['exp']; return exp is int ? exp : null; } catch (_) { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 65a8dec..0519784 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -102,7 +102,9 @@ class StorageService { return PickedDoc( bytes: f.bytes!, ext: ext, - mime: ext == 'pdf' ? 'application/pdf' : 'image/${ext == 'jpg' ? 'jpeg' : ext}', + mime: ext == 'pdf' + ? 'application/pdf' + : 'image/${ext == 'jpg' ? 'jpeg' : ext}', name: f.name, ); } @@ -112,7 +114,9 @@ class StorageService { final f = await pickImage(); if (f == null) return null; final bytes = await f.readAsBytes(); - var ext = f.name.contains('.') ? f.name.split('.').last.toLowerCase() : 'jpg'; + var ext = f.name.contains('.') + ? f.name.split('.').last.toLowerCase() + : 'jpg'; if (!const {'jpg', 'jpeg', 'png', 'webp'}.contains(ext)) ext = 'jpg'; return PickedDoc( bytes: bytes, @@ -123,10 +127,14 @@ class StorageService { } /// 비공개 버킷 업로드 — 반환값은 URL 이 아니라 '경로'(`//.`). - Future uploadBusinessDoc(PickedDoc doc, {required String kind}) async { + Future uploadBusinessDoc( + PickedDoc doc, { + required String kind, + }) async { final uid = SessionManager.instance.user?.id; if (uid == null) throw StateError('로그인이 필요합니다'); - final path = '$uid/$kind/${DateTime.now().millisecondsSinceEpoch}.${doc.ext}'; + final path = + '$uid/$kind/${DateTime.now().millisecondsSinceEpoch}.${doc.ext}'; await _c.storage .from('business-docs') .uploadBinary( @@ -138,9 +146,14 @@ class StorageService { } /// 비공개 서류 열람용 signed URL (기본 60초 — 화면 미리보기 용도). - Future businessDocSignedUrl(String path, {int expiresIn = 60}) async { + Future businessDocSignedUrl( + String path, { + int expiresIn = 60, + }) async { try { - return await _c.storage.from('business-docs').createSignedUrl(path, expiresIn); + return await _c.storage + .from('business-docs') + .createSignedUrl(path, expiresIn); } catch (_) { return null; } diff --git a/lib/widgets/facility_sheet.dart b/lib/widgets/facility_sheet.dart index 4a5b7e6..421005e 100644 --- a/lib/widgets/facility_sheet.dart +++ b/lib/widgets/facility_sheet.dart @@ -264,143 +264,140 @@ class _FacilityDetailContentState extends State { /// 헤더 아래 공통 본문(신뢰 카드·주소·전화·버튼·후기) — 사진 유무 양쪽 공용. List _bodyItems(Facility f, List? reviews) { return [ - if (evaluatePetSales(f) case final s?) ...[ - const SizedBox(height: 12), - _PetSalesTrustCard(score: s), - ], - if (f.address != null && f.address!.isNotEmpty) ...[ - const SizedBox(height: 12), - _row(Icons.place_outlined, f.address!), - ], - if (f.phone != null && f.phone!.isNotEmpty) ...[ - const SizedBox(height: 8), - _row(Icons.call_outlined, formatKrPhone(f.phone)), - ], - // 인증 업주가 설정한 영업시간(업체 정보 수정에서 동기화). - if ((f.businessHours ?? '').isNotEmpty) ...[ - const SizedBox(height: 8), - _row(Icons.schedule_outlined, f.businessHours!), - ], - const SizedBox(height: 16), - // 머티리얼 버튼은 무한 너비에서 maximumSize(∞)로 채우려다 터진다(이 - // 화면 본문은 너비 제약이 깨져 무한이 들어옴, #28). Container 는 intrinsic - // 크기라 무한에서도 안전 → GestureDetector+Container 로 버튼을 구성. - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 내 업체(인증 연결 계정)는 후기 쓰기 숨김 — 개인/업체 모드 무관 - // (같은 uid). 서버(add_facility_review own_facility)가 불변식 정본. - if (!_isMyFacility(f)) ...[ - GestureDetector( - onTap: _writeReview, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 12, - ), - decoration: BoxDecoration( - color: context.colors.primaryDark, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.rate_review_outlined, - size: 18, - color: Colors.white, - ), - const SizedBox(width: 6), - const Text( - '후기 쓰기', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - ], + if (evaluatePetSales(f) case final s?) ...[ + const SizedBox(height: 12), + _PetSalesTrustCard(score: s), + ], + if (f.address != null && f.address!.isNotEmpty) ...[ + const SizedBox(height: 12), + _row(Icons.place_outlined, f.address!), + ], + if (f.phone != null && f.phone!.isNotEmpty) ...[ + const SizedBox(height: 8), + _row(Icons.call_outlined, formatKrPhone(f.phone)), + ], + // 인증 업주가 설정한 영업시간(업체 정보 수정에서 동기화). + if ((f.businessHours ?? '').isNotEmpty) ...[ + const SizedBox(height: 8), + _row(Icons.schedule_outlined, f.businessHours!), + ], + const SizedBox(height: 16), + // 머티리얼 버튼은 무한 너비에서 maximumSize(∞)로 채우려다 터진다(이 + // 화면 본문은 너비 제약이 깨져 무한이 들어옴, #28). Container 는 intrinsic + // 크기라 무한에서도 안전 → GestureDetector+Container 로 버튼을 구성. + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 내 업체(인증 연결 계정)는 후기 쓰기 숨김 — 개인/업체 모드 무관 + // (같은 uid). 서버(add_facility_review own_facility)가 불변식 정본. + if (!_isMyFacility(f)) ...[ GestureDetector( - onTap: _openInNaverMap, + onTap: _writeReview, child: Container( padding: const EdgeInsets.symmetric( - horizontal: 14, + horizontal: 18, vertical: 12, ), decoration: BoxDecoration( + color: context.colors.primaryDark, borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.colors.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.map_outlined, + const Icon( + Icons.rate_review_outlined, size: 18, - color: context.colors.textSecondary, + color: Colors.white, ), - SizedBox(width: 6), - Text( - '네이버', - style: TextStyle(color: context.colors.textSecondary), + const SizedBox(width: 6), + const Text( + '후기 쓰기', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), ), ], ), ), ), + const SizedBox(width: 8), ], - ), - const SizedBox(height: 18), - Divider(height: 1, color: context.colors.border), - const SizedBox(height: 14), - Text( - '후기', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: context.colors.textPrimary, + GestureDetector( + onTap: _openInNaverMap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: context.colors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.map_outlined, + size: 18, + color: context.colors.textSecondary, + ), + SizedBox(width: 6), + Text( + '네이버', + style: TextStyle(color: context.colors.textSecondary), + ), + ], + ), + ), ), + ], + ), + const SizedBox(height: 18), + Divider(height: 1, color: context.colors.border), + const SizedBox(height: 14), + Text( + '후기', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: context.colors.textPrimary, ), - const SizedBox(height: 10), - if (reviews == null) - const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), - ) - else if (reviews.isEmpty) - Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Center( - child: Text( - '아직 후기가 없어요. 첫 후기를 남겨보세요!', - style: TextStyle(color: context.colors.textTertiary), - ), + ), + const SizedBox(height: 10), + if (reviews == null) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), + ) + else if (reviews.isEmpty) + Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text( + '아직 후기가 없어요. 첫 후기를 남겨보세요!', + style: TextStyle(color: context.colors.textTertiary), ), - ) - else - // 사진+평점 카드 그리드 — 탭하면 상세 시트(작성자·내용·사진 전체). - ReviewCardGrid( - reviews: [ - for (final r in reviews) - ReviewCardData( - author: r.authorNickname, - rating: r.rating, - content: r.content, - createdAt: r.createdAt, - photoUrls: r.photoUrls, - isMine: r.isMine, - visitNo: r.visitNo, - seed: r.id, - reviewId: r.id, - authorUserId: r.userId, - onDelete: r.isMine ? () => _deleteReview(r.id) : null, - ), - ], ), + ) + else + // 사진+평점 카드 그리드 — 탭하면 상세 시트(작성자·내용·사진 전체). + ReviewCardGrid( + reviews: [ + for (final r in reviews) + ReviewCardData( + author: r.authorNickname, + rating: r.rating, + content: r.content, + createdAt: r.createdAt, + photoUrls: r.photoUrls, + isMine: r.isMine, + visitNo: r.visitNo, + seed: r.id, + reviewId: r.id, + authorUserId: r.userId, + onDelete: r.isMine ? () => _deleteReview(r.id) : null, + ), + ], + ), ]; } diff --git a/lib/widgets/review_cards.dart b/lib/widgets/review_cards.dart index b79105b..ae6a35a 100644 --- a/lib/widgets/review_cards.dart +++ b/lib/widgets/review_cards.dart @@ -119,7 +119,8 @@ class _ReviewCard extends StatelessWidget { // 위에 본문을 히어로로 올린다. Positioned.fill( child: BlobBackground( - seed: review.seed ?? + seed: + review.seed ?? '${review.author}/${review.createdAt?.millisecondsSinceEpoch}', color: context.colors.primary, ), diff --git a/lib/widgets/user_tile.dart b/lib/widgets/user_tile.dart index 53385bd..340e8c3 100644 --- a/lib/widgets/user_tile.dart +++ b/lib/widgets/user_tile.dart @@ -47,7 +47,9 @@ class UserTile extends StatelessWidget { /// 이면 배지와 같은 위젯을 Opacity 0 으로 두어 높이를 통일한다. Widget _statLine(BuildContext context, bool hasPhoto) { final c = connection; - if (c.reviewCount == null && c.pawingCount == null && c.pawmateCount == null) { + if (c.reviewCount == null && + c.pawingCount == null && + c.pawmateCount == null) { return Opacity(opacity: 0, child: _badgeRow(context, hasPhoto)); } final sub = hasPhoto diff --git a/test/address_sheet_repro_test.dart b/test/address_sheet_repro_test.dart index 698f7ce..c950e14 100644 --- a/test/address_sheet_repro_test.dart +++ b/test/address_sheet_repro_test.dart @@ -30,7 +30,10 @@ void main() { ), ); - for (final (label, theme) in [('light', AppTheme.light()), ('dark', AppTheme.dark())]) { + for (final (label, theme) in [ + ('light', AppTheme.light()), + ('dark', AppTheme.dark()), + ]) { testWidgets('주소검색 시트가 열리고 전환이 정상 종료된다 ($label)', (tester) async { await tester.pumpWidget(host(theme)); await tester.tap(find.text('open')); diff --git a/test/facility_sheet_test.dart b/test/facility_sheet_test.dart index d2e48ac..6f48889 100644 --- a/test/facility_sheet_test.dart +++ b/test/facility_sheet_test.dart @@ -32,16 +32,18 @@ void main() { ); testWidgets('시설 상세 콘텐츠가 정보와 후기 버튼을 표시한다', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: FacilityDetailContent( - facility: facility, - color: Color(0xFFEF5350), - label: '동물병원', + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: FacilityDetailContent( + facility: facility, + color: Color(0xFFEF5350), + label: '동물병원', + ), ), ), - )); + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); @@ -55,22 +57,24 @@ void main() { testWidgets('MapBottomSheet 가 콘텐츠를 띄우고 바깥 탭으로 닫힌다', (tester) async { var closed = false; - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: Stack( - children: [ - const Positioned.fill(child: ColoredBox(color: Colors.green)), - Positioned.fill( - child: MapBottomSheet( - onClose: () => closed = true, - child: const Center(child: Text('SHEET-BODY')), + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.green)), + Positioned.fill( + child: MapBottomSheet( + onClose: () => closed = true, + child: const Center(child: Text('SHEET-BODY')), + ), ), - ), - ], + ], + ), ), ), - )); + ); // 슬라이드 인. await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); @@ -100,27 +104,29 @@ void main() { ownerUserId: 'owner-uid', ); final obs = _RecordingObserver(); - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - navigatorObservers: [obs], - home: Scaffold( - body: Stack( - children: [ - const Positioned.fill(child: ColoredBox(color: Colors.green)), - Positioned.fill( - child: MapBottomSheet( - onClose: () {}, - child: FacilityDetailContent( - facility: hero, - color: const Color(0xFFEF5350), - label: '분양', + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + navigatorObservers: [obs], + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.green)), + Positioned.fill( + child: MapBottomSheet( + onClose: () {}, + child: FacilityDetailContent( + facility: hero, + color: const Color(0xFFEF5350), + label: '분양', + ), ), ), - ), - ], + ], + ), ), ), - )); + ); // 슬라이드 인 + 후기 로드 폴백. await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); diff --git a/test/helpers/fake_session.dart b/test/helpers/fake_session.dart index 48ae4d6..c41bf19 100644 --- a/test/helpers/fake_session.dart +++ b/test/helpers/fake_session.dart @@ -13,27 +13,27 @@ Map installFakeSecureStorage() { const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { - final args = (call.arguments as Map?) ?? const {}; - final key = args['key'] as String?; - switch (call.method) { - case 'read': - return store[key]; - case 'write': - store[key!] = args['value'] as String; + final args = (call.arguments as Map?) ?? const {}; + final key = args['key'] as String?; + switch (call.method) { + case 'read': + return store[key]; + case 'write': + store[key!] = args['value'] as String; + return null; + case 'delete': + store.remove(key); + return null; + case 'readAll': + return Map.from(store); + case 'deleteAll': + store.clear(); + return null; + case 'containsKey': + return store.containsKey(key); + } return null; - case 'delete': - store.remove(key); - return null; - case 'readAll': - return Map.from(store); - case 'deleteAll': - store.clear(); - return null; - case 'containsKey': - return store.containsKey(key); - } - return null; - }); + }); return store; } diff --git a/test/region_posts_sheet_test.dart b/test/region_posts_sheet_test.dart index 5dcbe4a..61a9acd 100644 --- a/test/region_posts_sheet_test.dart +++ b/test/region_posts_sheet_test.dart @@ -16,12 +16,12 @@ void main() { ); testWidgets('동네 게시글 콘텐츠가 헤더를 표시하고 예외 없이 렌더된다', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: RegionPostsContent(cluster: cluster), + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: RegionPostsContent(cluster: cluster)), ), - )); + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); diff --git a/test/review_detail_test.dart b/test/review_detail_test.dart index 3c9ee8d..9d2c62e 100644 --- a/test/review_detail_test.dart +++ b/test/review_detail_test.dart @@ -21,10 +21,12 @@ void main() { tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: ReviewDetailScreen(review: review), - )); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: ReviewDetailScreen(review: review), + ), + ); await tester.pump(); expect(tester.takeException(), isNull); @@ -53,24 +55,26 @@ void main() { return true; }, ); - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: Builder( - builder: (context) => Scaffold( - body: Center( - child: TextButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ReviewDetailScreen(review: mine), + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReviewDetailScreen(review: mine), + ), ), + child: const Text('OPEN'), ), - child: const Text('OPEN'), ), ), ), ), - )); + ); await tester.tap(find.text('OPEN')); await tester.pumpAndSettle(); @@ -89,12 +93,18 @@ void main() { tester.view.physicalSize = const Size(800, 2400); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); - await tester.pumpWidget(MaterialApp( - theme: AppTheme.light(), - home: Scaffold( - body: ListView(children: [ReviewCardGrid(reviews: [review])]), + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: ListView( + children: [ + ReviewCardGrid(reviews: [review]), + ], + ), + ), ), - )); + ); await tester.pump(); await tester.tap(find.byType(InkWell).first); diff --git a/test/services/auth_flow_test.dart b/test/services/auth_flow_test.dart index d27b0df..c0dcd00 100644 --- a/test/services/auth_flow_test.dart +++ b/test/services/auth_flow_test.dart @@ -8,7 +8,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fake_session.dart'; import '../helpers/fake_supabase.dart'; -const _me = AuthUser(id: 'u1', username: 'me', nickname: '나', userType: 'no_pet'); +const _me = AuthUser( + id: 'u1', + username: 'me', + nickname: '나', + userType: 'no_pet', +); void main() { late Map secureStore; @@ -66,17 +71,19 @@ void main() { group('AuthService.changePassword — 세션 교체 흐름', () { setUp(() async { - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 3600), _me, refresh: 'r-old'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 3600), + _me, + refresh: 'r-old', + ); }); test('성공 시 새 access/refresh 로 세션이 통째로 교체된다', () async { final newJwt = jwtWithExp(nowSec() + 7200); - FakeSupabase.on('change-password', (_) => { - 'ok': true, - 'token': newJwt, - 'refresh_token': 'r-new', - }); + FakeSupabase.on( + 'change-password', + (_) => {'ok': true, 'token': newJwt, 'refresh_token': 'r-new'}, + ); final r = await AuthService.instance.changePassword('old', 'new123'); diff --git a/test/services/chat_model_test.dart b/test/services/chat_model_test.dart index 25d70c0..633be7f 100644 --- a/test/services/chat_model_test.dart +++ b/test/services/chat_model_test.dart @@ -34,7 +34,10 @@ void main() { }); test('상대가 고객센터면 isSupport(목록 최상단 고정 근거)', () { - final r = ChatRoomSummary.fromJson({'id': 'r1', 'other_nickname': '고객센터'}); + final r = ChatRoomSummary.fromJson({ + 'id': 'r1', + 'other_nickname': '고객센터', + }); expect(r.isSupport, isTrue); }); }); @@ -63,8 +66,10 @@ void main() { isFalse, ); expect( - ChatMessage.fromJson({...row, 'image_url': 'https://x/y.jpg'}, 'u1') - .isImage, + ChatMessage.fromJson({ + ...row, + 'image_url': 'https://x/y.jpg', + }, 'u1').isImage, isTrue, ); }); diff --git a/test/services/chat_repository_test.dart b/test/services/chat_repository_test.dart index a4f7233..b66cb66 100644 --- a/test/services/chat_repository_test.dart +++ b/test/services/chat_repository_test.dart @@ -25,11 +25,14 @@ void main() { group('ChatRepository.fetchRooms', () { test('고객센터 방을 최상단으로 올리고 나머지는 서버 순서(최근 메시지순) 유지', () async { - FakeSupabase.on('v_chat_rooms', (_) => [ - room('r1', '멍집사', at: '2026-07-03T00:00:00Z'), - room('r2', '고객센터', at: '2026-07-02T00:00:00Z'), - room('r3', '냥집사', at: '2026-07-01T00:00:00Z'), - ]); + FakeSupabase.on( + 'v_chat_rooms', + (_) => [ + room('r1', '멍집사', at: '2026-07-03T00:00:00Z'), + room('r2', '고객센터', at: '2026-07-02T00:00:00Z'), + room('r3', '냥집사', at: '2026-07-01T00:00:00Z'), + ], + ); final rooms = await ChatRepository.instance.fetchRooms(); diff --git a/test/services/community_model_test.dart b/test/services/community_model_test.dart index 10e81ac..f091e35 100644 --- a/test/services/community_model_test.dart +++ b/test/services/community_model_test.dart @@ -47,7 +47,11 @@ void main() { }); test('카운트가 double 로 와도 int 로 수렴한다(PostgREST numeric)', () { - final p = Post.fromJson({'id': 'p1', 'heart_count': 3.0, 'view_count': 7.9}); + final p = Post.fromJson({ + 'id': 'p1', + 'heart_count': 3.0, + 'view_count': 7.9, + }); expect(p.heartCount, 3); expect(p.viewCount, 7); }); @@ -57,7 +61,10 @@ void main() { }); test('edited_at 이 있으면 isEdited', () { - final p = Post.fromJson({'id': 'p1', 'edited_at': '2026-07-01T00:00:00Z'}); + final p = Post.fromJson({ + 'id': 'p1', + 'edited_at': '2026-07-01T00:00:00Z', + }); expect(p.isEdited, isTrue); }); }); @@ -92,7 +99,10 @@ void main() { test('둘 중 하나라도 없거나 비면 이동 아님(경고 안 띄움)', () { expect(post(authorAddress: null, location: '청운동').authorMoved, isFalse); - expect(post(authorAddress: '서울 종로구 청운동', location: null).authorMoved, isFalse); + expect( + post(authorAddress: '서울 종로구 청운동', location: null).authorMoved, + isFalse, + ); expect(post(authorAddress: ' ', location: '청운동').authorMoved, isFalse); }); }); @@ -138,7 +148,12 @@ void main() { const p = MyPet(id: 'x', name: '뽀삐', species: '믹스', role: 'owner'); expect(p.isTrusted, isFalse); const q = MyPet( - id: 'x', name: '뽀삐', species: '믹스', role: 'owner', trustScore: 3); + id: 'x', + name: '뽀삐', + species: '믹스', + role: 'owner', + trustScore: 3, + ); expect(q.isTrusted, isTrue); }); }); diff --git a/test/services/community_repository_test.dart b/test/services/community_repository_test.dart index 65b11ca..df3a8a8 100644 --- a/test/services/community_repository_test.dart +++ b/test/services/community_repository_test.dart @@ -83,17 +83,24 @@ void main() { group('CommunityRepository.fetchUserPosts — 얼굴(authoredAs) 필터', () { test('authoredAs 지정 시 posts 의 모드 맵과 병합해 해당 얼굴 글만 남긴다', () async { FakeSupabase.on('v_post_feed', (_) => [postRow('p1'), postRow('p2')]); - FakeSupabase.on('/rest/v1/posts', (_) => [ - {'id': 'p1', 'authored_as': 'business'}, - // p2 는 모드 행 없음 → personal 로 간주 - ]); + FakeSupabase.on( + '/rest/v1/posts', + (_) => [ + {'id': 'p1', 'authored_as': 'business'}, + // p2 는 모드 행 없음 → personal 로 간주 + ], + ); - final business = await CommunityRepository.instance - .fetchUserPosts('u1', authoredAs: 'business'); + final business = await CommunityRepository.instance.fetchUserPosts( + 'u1', + authoredAs: 'business', + ); expect(business.map((p) => p.id), ['p1']); - final personal = await CommunityRepository.instance - .fetchUserPosts('u1', authoredAs: 'personal'); + final personal = await CommunityRepository.instance.fetchUserPosts( + 'u1', + authoredAs: 'personal', + ); expect(personal.map((p) => p.id), ['p2']); }); @@ -101,8 +108,10 @@ void main() { FakeSupabase.on('v_post_feed', (_) => [postRow('p1'), postRow('p2')]); FakeSupabase.on('/rest/v1/posts', (_) => '이상한 응답'); - final posts = await CommunityRepository.instance - .fetchUserPosts('u1', authoredAs: 'business'); + final posts = await CommunityRepository.instance.fetchUserPosts( + 'u1', + authoredAs: 'business', + ); expect(posts, hasLength(2), reason: '표시 누락보다 전체 유지가 안전'); }); diff --git a/test/services/notification_repository_test.dart b/test/services/notification_repository_test.dart index dd3783d..0f37ad0 100644 --- a/test/services/notification_repository_test.dart +++ b/test/services/notification_repository_test.dart @@ -10,7 +10,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fake_session.dart'; import '../helpers/fake_supabase.dart'; -const _me = AuthUser(id: 'u1', username: 'me', nickname: '나', userType: 'no_pet'); +const _me = AuthUser( + id: 'u1', + username: 'me', + nickname: '나', + userType: 'no_pet', +); void main() { setUpAll(() async { @@ -23,20 +28,26 @@ void main() { setUp(() async { FakeSupabase.reset(); SharedPreferences.setMockInitialValues({}); - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 3600), _me, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 3600), + _me, + refresh: 'r1', + ); }); group('NotificationRepository.fetch', () { test('내 것 + 비무음만 최신순 100건 요청하고 타입 폴백 파싱', () async { - FakeSupabase.on('notifications', (_) => [ - { - 'id': 'n1', - 'notification_type': null, // 미지정 → system_notice 폴백 - 'is_read': false, - 'created_at': '2026-07-01T00:00:00Z', - }, - ]); + FakeSupabase.on( + 'notifications', + (_) => [ + { + 'id': 'n1', + 'notification_type': null, // 미지정 → system_notice 폴백 + 'is_read': false, + 'created_at': '2026-07-01T00:00:00Z', + }, + ], + ); final list = await NotificationRepository.instance.fetch(); diff --git a/test/services/session_manager_test.dart b/test/services/session_manager_test.dart index c7056b0..c843cab 100644 --- a/test/services/session_manager_test.dart +++ b/test/services/session_manager_test.dart @@ -31,27 +31,27 @@ void main() { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { - final args = (call.arguments as Map?) ?? const {}; - final key = args['key'] as String?; - switch (call.method) { - case 'read': - return secureStore[key]; - case 'write': - secureStore[key!] = args['value'] as String; + final args = (call.arguments as Map?) ?? const {}; + final key = args['key'] as String?; + switch (call.method) { + case 'read': + return secureStore[key]; + case 'write': + secureStore[key!] = args['value'] as String; + return null; + case 'delete': + secureStore.remove(key); + return null; + case 'readAll': + return Map.from(secureStore); + case 'deleteAll': + secureStore.clear(); + return null; + case 'containsKey': + return secureStore.containsKey(key); + } return null; - case 'delete': - secureStore.remove(key); - return null; - case 'readAll': - return Map.from(secureStore); - case 'deleteAll': - secureStore.clear(); - return null; - case 'containsKey': - return secureStore.containsKey(key); - } - return null; - }); + }); secureStore.clear(); SharedPreferences.setMockInitialValues({}); await SessionManager.instance.clear(); @@ -59,24 +59,30 @@ void main() { group('isAccessExpiringSoon — 무중단 갱신 트리거 판정', () { test('만료 60초 이내면 임박', () async { - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 30), _user, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 30), + _user, + refresh: 'r1', + ); expect(SessionManager.instance.isAccessExpiringSoon(), isTrue); }); test('만료가 충분히 남았으면 임박 아님', () async { - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 3600), _user, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 3600), + _user, + refresh: 'r1', + ); expect(SessionManager.instance.isAccessExpiringSoon(), isFalse); }); test('skew 를 넓히면 같은 토큰도 임박으로 판정', () async { - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 3600), _user, refresh: 'r1'); - expect( - SessionManager.instance.isAccessExpiringSoon(skew: 7200), - isTrue, + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 3600), + _user, + refresh: 'r1', ); + expect(SessionManager.instance.isAccessExpiringSoon(skew: 7200), isTrue); }); test('refresh 미보유(레거시 30일 토큰)는 만료 임박이어도 갱신 안 함', () async { @@ -85,8 +91,11 @@ void main() { }); test('exp 를 읽을 수 없는 토큰(형식 불량)은 임박으로 보지 않는다', () async { - await SessionManager.instance - .setSession('not-a-jwt', _user, refresh: 'r1'); + await SessionManager.instance.setSession( + 'not-a-jwt', + _user, + refresh: 'r1', + ); expect(SessionManager.instance.isAccessExpiringSoon(), isFalse); }); }); @@ -100,10 +109,7 @@ void main() { expect(secureStore['session_access'], t); expect(secureStore['session_refresh'], 'r1'); final prefs = await SharedPreferences.getInstance(); - expect( - jsonDecode(prefs.getString('session_user')!)['id'], - 'u1', - ); + expect(jsonDecode(prefs.getString('session_user')!)['id'], 'u1'); }); test('load 는 구버전 prefs 토큰을 secure storage 로 마이그레이션한다', () async { @@ -132,8 +138,11 @@ void main() { }); test('clear 는 메모리·secure·prefs 를 모두 비운다', () async { - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 100), _user, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 100), + _user, + refresh: 'r1', + ); await SessionManager.instance.clear(); expect(SessionManager.instance.isLoggedIn, isFalse); diff --git a/test/services/session_refresh_test.dart b/test/services/session_refresh_test.dart index 2d71494..dffd716 100644 --- a/test/services/session_refresh_test.dart +++ b/test/services/session_refresh_test.dart @@ -5,7 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fake_session.dart'; import '../helpers/fake_supabase.dart'; -const _me = AuthUser(id: 'u1', username: 'me', nickname: '나', userType: 'no_pet'); +const _me = AuthUser( + id: 'u1', + username: 'me', + nickname: '나', + userType: 'no_pet', +); void main() { late Map secureStore; @@ -22,8 +27,11 @@ void main() { secureStore.clear(); SharedPreferences.setMockInitialValues({}); SessionManager.instance.onInvalidated = null; - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 30), _me, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 30), + _me, + refresh: 'r1', + ); FakeSupabase.requests.clear(); }); @@ -31,18 +39,16 @@ void main() { SessionManager.instance.onInvalidated = null; }); - int refreshCalls() => FakeSupabase.requests - .where((r) => r.url.path.contains('refresh')) - .length; + int refreshCalls() => + FakeSupabase.requests.where((r) => r.url.path.contains('refresh')).length; group('SessionManager.refreshOnce — 단일비행 토큰 회전', () { test('동시에 여러 번 불러도 refresh 요청은 1회, 토큰·refresh 가 회전된다', () async { final newJwt = jwtWithExp(nowSec() + 7200); - FakeSupabase.on('refresh', (_) => { - 'ok': true, - 'token': newJwt, - 'refresh_token': 'r2', - }); + FakeSupabase.on( + 'refresh', + (_) => {'ok': true, 'token': newJwt, 'refresh_token': 'r2'}, + ); await Future.wait([ SessionManager.instance.refreshOnce(), @@ -58,11 +64,14 @@ void main() { }); test('완료 후 다시 부르면 새 요청이 나간다(비행 종료 후 재사용 아님)', () async { - FakeSupabase.on('refresh', (_) => { - 'ok': true, - 'token': jwtWithExp(nowSec() + 7200), - 'refresh_token': 'r2', - }); + FakeSupabase.on( + 'refresh', + (_) => { + 'ok': true, + 'token': jwtWithExp(nowSec() + 7200), + 'refresh_token': 'r2', + }, + ); await SessionManager.instance.refreshOnce(); await SessionManager.instance.refreshOnce(); diff --git a/test/services/social_repository_test.dart b/test/services/social_repository_test.dart index 8438c6a..6f6f41e 100644 --- a/test/services/social_repository_test.dart +++ b/test/services/social_repository_test.dart @@ -9,7 +9,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/fake_session.dart'; import '../helpers/fake_supabase.dart'; -const _me = AuthUser(id: 'u1', username: 'me', nickname: '나', userType: 'no_pet'); +const _me = AuthUser( + id: 'u1', + username: 'me', + nickname: '나', + userType: 'no_pet', +); Map profileRow(String id, {String? businessName}) => { 'id': id, @@ -34,8 +39,11 @@ void main() { setUp(() async { FakeSupabase.reset(); SharedPreferences.setMockInitialValues({}); - await SessionManager.instance - .setSession(jwtWithExp(nowSec() + 3600), _me, refresh: 'r1'); + await SessionManager.instance.setSession( + jwtWithExp(nowSec() + 3600), + _me, + refresh: 'r1', + ); }); group('SocialRepository.follow/unfollow — 얼굴 단위 팔로우', () { @@ -73,9 +81,12 @@ void main() { }); test('isFollowing 은 행 존재 여부로 판정', () async { - FakeSupabase.on('pawings', (_) => [ - {'following_id': 'u2'}, - ]); + FakeSupabase.on( + 'pawings', + (_) => [ + {'following_id': 'u2'}, + ], + ); expect(await SocialRepository.instance.isFollowing('u2'), isTrue); FakeSupabase.on('pawings', (_) => []); @@ -105,9 +116,12 @@ void main() { return [profileRow('u2')]; }); // 나는 u3 의 '업체 얼굴'만 팔로우 중. - FakeSupabase.on('pawings', (_) => [ - {'following_id': 'u3', 'context': 'business'}, - ]); + FakeSupabase.on( + 'pawings', + (_) => [ + {'following_id': 'u3', 'context': 'business'}, + ], + ); final list = await SocialRepository.instance.searchUsers('멍'); @@ -131,9 +145,12 @@ void main() { } return []; }); - FakeSupabase.on('pawings', (_) => [ - {'following_id': 'u3', 'context': 'personal'}, - ]); + FakeSupabase.on( + 'pawings', + (_) => [ + {'following_id': 'u3', 'context': 'personal'}, + ], + ); final list = await SocialRepository.instance.searchUsers('멍');