Compare commits
3 Commits
bcc26f5e79
...
99ad8a3bd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ad8a3bd5 | ||
|
|
2857fe1cb6 | ||
|
|
6a3e8f30d8 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -52,6 +52,11 @@ local.properties
|
||||
/android/local.properties
|
||||
/ios/Flutter/ephemeral/
|
||||
|
||||
# Keystore & secrets
|
||||
*.jks
|
||||
*.keystore
|
||||
doc/key/
|
||||
|
||||
# Test Hive files
|
||||
test_hive/
|
||||
|
||||
|
||||
52
doc/store_desc/store_description.md
Normal file
52
doc/store_desc/store_description.md
Normal 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분 안에 끝내 보세요.
|
||||
※ 본 서비스는 현재 한국(대한민국) 지역에서만 사용 가능합니다.
|
||||
40
doc/ux_calendar_sync_plan.md
Normal file
40
doc/ux_calendar_sync_plan.md
Normal 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 혹은 디자인 가이드) 업데이트 여부 검토.
|
||||
@@ -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('삭제'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user