Compare commits

...

3 Commits

Author SHA1 Message Date
JiWoong Sul
99ad8a3bd5 chore(app): 추천 기록 탭 대응과 스플래시 대기 제한
- RecommendationRecordCard에 onTap 콜백을 추가하고 카드 전체를 InkWell로 감싸 탭 제스처를 받을 수 있게 함\n- _navigateToHome에서 권한 요청 Future를 5초 타임아웃으로 감싸 스플래시에서 무한 대기를 막고, 완료 여부와 관계없이 홈으로 이동하도록 정리\n- 변경 의도 주석을 추가해 동작 맥락을 명시
2025-12-04 16:35:27 +09:00
JiWoong Sul
2857fe1cb6 chore(repo): keystore 아티팩트 무시
- doc/key/ 디렉터리와 keystore 확장자를 무시해 인증서/비밀번호 노출을 방지
2025-12-04 16:35:15 +09:00
JiWoong Sul
6a3e8f30d8 docs(store): 스토어 설명과 캘린더 UX 계획 추가
- 앱 스토어용 한 줄/상세 설명 초안을 정리\n- 캘린더·통계/추천 화면 개선 범위와 검증 항목을 문서화
2025-12-04 16:35:10 +09:00
5 changed files with 237 additions and 124 deletions

5
.gitignore vendored
View File

@@ -52,6 +52,11 @@ local.properties
/android/local.properties
/ios/Flutter/ephemeral/
# Keystore & secrets
*.jks
*.keystore
doc/key/
# Test Hive files
test_hive/

View File

@@ -0,0 +1,52 @@
한 줄 설명
직장인 점심 고민 끝, 날씨·거리 맞춘 랜덤 맛집 추천
상세 설명
매일 점심마다 “오늘 뭐 먹지?” 하며 10분 넘게 고민하다가, 결국 늘 가던 곳만 돌고 있지 않으신가요?
‘오늘 뭐 먹Z?’는 한국 직장인을 위해 날씨와 거리, 최근 방문 기록까지 고려해서, 내가 등록해 둔 믿을 수 있는 맛집들 중 딱 한 곳을 랜덤으로 골라주는 스마트 점심 추천 앱입니다. 메뉴 고민 시간을 10~15분에서 1분으로 줄여 보세요.
■ 이런 분께 딱이에요
- 회사 주변 맛집은 많은데, 무엇을 갈지 매번 고르기 힘든 직장인
- 새로운 가게도 가고 싶지만, 연속 방문은 피하고 싶은 분
- 팀 점심·회식 메뉴를 빠르게 정해야 하는 리더/매니저
- 내가 어디를 얼마나 자주 갔는지, 데이터로 관리하고 싶은 사람
■ 주요 기능
1. 날씨·거리 기반 랜덤 추천
- 현재 날씨와 1시간 후 예보를 함께 보여주고, 비 오는 날에는 자동으로 가까운 맛집 위주로 추천
- 최대 이동 거리(100m~2km)를 슬라이더로 설정하면, 그 안에 있는 가게만 후보로 사용
- n일 이내 방문한 가게는 자동 제외하는 알고리즘으로, 연속 방문을 막고 메뉴 다양성 보장
- 한식·중식·일식·카페 등 카테고리를 선택해, 그날 기분에 맞는 맛집만 골라서 추천
2. 네이버 지도 연동 맛집 수집
- 네이버 지도앱에서 공유한 링크(naver.me 등)를 그대로 붙여넣으면, 가게 이름·주소·카테고리·좌표를 자동으로 불러와 등록
- 회사 구내식당이나 단골 분식집은 직접 입력으로 손쉽게 등록
- 메모, 전화번호까지 함께 저장해 두고, 동료에게 설명할 때도 한 번에 보여줄 수 있습니다.
3. 캘린더 & 통계로 보는 나의 점심 히스토리
- 월별 캘린더에서 ‘추천받은 날’과 ‘실제 방문한 날’을 다른 색 마커로 한눈에 확인
- 특정 날짜를 누르면, 그날 추천·방문 기록과 가게 상세 정보까지 한 번에 조회
- 월별 총 방문 횟수, 가장 많이 간 카테고리, 최근 7일 방문 패턴, 자주 방문한 맛집 TOP 3를 통계 카드로 제공
- “오늘 어디 갔더라?”를 기억하지 않아도, 캘린더와 통계가 내가 쌓아온 점심 히스토리를 정리해 줍니다.
4. Bluetooth 리스트 공유로 팀 점심까지 한번에
- Bluetooth 기반 리스트 공유로, 같은 공간에 있는 동료와 내 맛집 리스트를 간편하게 주고받기
- 공유 코드를 생성해 내 리스트를 보내고, 상대의 리스트를 받아 합쳐 팀 공용 맛집 풀(pool) 구성
- 이미 등록된 가게는 자동으로 걸러주고, 새로운 맛집만 골라 추가해 중복 없이 리스트를 확장
5. 스마트 알림 & 편의 기능
- 점심 후 일정 시간이 지나면 ‘오늘 추천받은 곳, 실제로 갔나요? 방문 확인 알림을 보내 기록 누락 방지
- 알림에서 한 번만 눌러도 방문 완료로 저장되어, 캘린더·통계에 자동 반영
- 다크 모드 지원, 한국어에 최적화된 깔끔한 UI로 누구나 직관적으로 사용 가능
- 광고 시청 후 1Tap으로 추천을 받는 구조로, 앱은 무료로 이용하면서 개발자는 지속적으로 서비스를 개선할 수 있습니다.
■ 왜 ‘오늘 뭐 먹Z?’여야 할까요?
- 내 주변 ‘아무 식당’이 아니라, 내가 직접 고른 맛집들만 기준으로 추천해 실패 확률을 줄여 줍니다.
- 날씨·거리·카테고리·최근 방문 이력을 모두 고려해, “오늘은 여기 가야겠다”라는 결정을 대신 내려줍니다.
- 캘린더와 통계 화면 덕분에, 점심 시간이 단순 소비가 아니라 나만의 작은 라이프로그가 됩니다.
- 팀 점심·회식까지 한 앱으로 해결해, 메뉴 선택 스트레스를 팀 전체에서 줄일 수 있습니다.
- 지금 ‘오늘 뭐 먹Z?’를 설치하고, 한국 직장인의 가장 큰 고민 중 하나인 점심 메뉴 선택을 1분 안에 끝내 보세요.
※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다.

View File

@@ -0,0 +1,40 @@
# 캘린더·통계 UX 개선 작업 계획
## 목적
- 캘린더/통계 화면에서 월 동기화 불일치를 제거하고 과거·현재 데이터를 온전히 노출.
- 기록 가독성(마커 색/범례/헤더)과 액션 동선(상세/수정/CTA 안내)을 개선해 사용자 혼란을 최소화.
- 추천 화면의 오류/비활성 안내를 명확히 해 추천 흐름 이탈을 줄임.
## 범위
- 캘린더/통계 UI·상태 동기화: `lib/presentation/pages/calendar/calendar_screen.dart`, `lib/presentation/pages/calendar/widgets/visit_statistics.dart`
- 추천 화면 오류/CTA 상태 안내: `lib/presentation/pages/random_selection/random_selection_screen.dart`
- 마커/범례 시각 일관성: 동일 파일 내 스타일 및 범례 영역
- 기록 액션 라우팅: 방문/추천 카드 탭 액션 연결
## 작업 항목
- [x] 달력 범위 동적 설정
- `TableCalendar.firstDay/lastDay`를 데이터 최소일~현재+1년 등 동적 값으로 계산해 2024·과거 기록도 조회 가능하게 조정.
- [x] 월 이동 시 통계 동기화
- `TableCalendar.onPageChanged`(또는 `onHeaderTapped`)로 `_focusedDay` 업데이트 후 `VisitStatistics`에 전달하는 월을 갱신.
- 필요 시 월 상태를 Provider/State로 분리해 탭 간 단일 소스로 관리.
- [x] 월 선택 드롭다운/빠른 이동
- 통계 카드 헤더에 월 선택 UI(드롭다운/피커) 추가해 과거 월로 점프 가능하게 구성.
- [x] 마커·범례 색상 일치 및 대비 강화
- 방문/추천 색 팔레트 정의(라이트/다크 대응) 후 `markerBuilder`와 범례 색을 일치.
- 추천/방문 아이콘·툴팁 추가로 이벤트 구분성 강화.
- [x] 일별 기록 헤더·액션 정교화
- 헤더 문구/카운트에서 방문·추천을 분리 표기(예: `기록 3건 · 방문2/추천1`).
- 방문/추천 카드 `onTap`에 상세/수정/확인 라우팅 연결.
- [x] 추천 화면 오류/비활성 안내
- 날씨 로딩 실패 시 실제 상태(재시도/권한 확인) 메시지로 교체, 임의 날씨 표시 제거.
- 추천 버튼 비활성 사유를 UI로 노출(위치 준비 중/맛집 0개/거리 내 없음 등)하고 해결 가이드 제시.
## 검증
- 라이트/다크 모드에서 마커·범례·CTA 대비 확인.
- 과거 월/현재 월/미래 월 이동 시 캘린더·통계 동기화 확인.
- 데이터 없음·권한 거부·위치 실패·날씨 실패 상태별 UI 확인.
- iOS/Android에서 추천 CTA 비활성/활성 전환 및 스낵바/다이얼로그 메시지 확인.
## 후속 조치
- QA 시나리오 통과 후 `flutter analyze`, 필요 시 관련 `flutter test` 실행.
- 변경 사항에 맞춰 스크린샷/문서(README 혹은 디자인 가이드) 업데이트 여부 검토.

View File

@@ -10,11 +10,15 @@ class RecommendationRecordCard extends ConsumerWidget {
final VoidCallback onConfirmVisit;
final VoidCallback onDelete;
/// 카드 전체 탭(tap) 시 실행할 콜백.
final VoidCallback? onTap;
const RecommendationRecordCard({
super.key,
required this.recommendation,
required this.onConfirmVisit,
required this.onDelete,
this.onTap,
});
String _formatTime(DateTime dateTime) {
@@ -43,130 +47,137 @@ class RecommendationRecordCard extends ConsumerWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.whatshot,
color: Colors.orange,
size: 24,
),
),
child: const Icon(
Icons.whatshot,
color: Colors.orange,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.category_outlined,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
restaurant.category,
style: AppTypography.caption(isDark),
),
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
_formatTime(recommendation.recommendationDate),
style: AppTypography.caption(isDark),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
style: AppTypography.caption(isDark).copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
softWrap: true,
maxLines: 3,
overflow: TextOverflow.visible,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.category_outlined,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
),
],
),
],
const SizedBox(width: 4),
Text(
restaurant.category,
style: AppTypography.caption(isDark),
),
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 14,
color: isDark
? AppColors.darkTextSecondary
: AppColors.lightTextSecondary,
),
const SizedBox(width: 4),
Text(
_formatTime(
recommendation.recommendationDate,
),
style: AppTypography.caption(isDark),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.info_outline,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
style: AppTypography.caption(isDark)
.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
softWrap: true,
maxLines: 3,
overflow: TextOverflow.visible,
),
),
],
),
],
),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: onConfirmVisit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
const SizedBox(width: 12),
ElevatedButton(
onPressed: onConfirmVisit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightPrimary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
),
child: const Text('방문 확인'),
),
child: const Text('방문 확인'),
),
],
),
const SizedBox(height: 10),
Container(
height: 1,
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
padding: const EdgeInsets.only(top: 6),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('삭제'),
],
),
),
],
const SizedBox(height: 10),
Container(
height: 1,
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onDelete,
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
padding: const EdgeInsets.only(top: 6),
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('삭제'),
),
),
],
),
),
),
);

View File

@@ -247,13 +247,18 @@ class _SplashScreenState extends State<SplashScreen>
}
void _navigateToHome() {
// 권한 요청이 지연되어도 스플래시(Splash) 화면이 멈추지 않도록 최대 5초만 대기한다.
final permissionFuture = _ensurePermissions().timeout(
const Duration(seconds: 5),
onTimeout: () {},
);
Future.wait([
_ensurePermissions(),
permissionFuture,
Future.delayed(AppConstants.splashAnimationDuration),
]).then((_) {
if (mounted) {
context.go('/home');
}
]).whenComplete(() {
if (!mounted) return;
context.go('/home');
});
}