Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

이 프로젝트의 모든 변경사항은 이 파일에 기록됩니다.

## [3.0.0] - 2026-03-02

### Added
- 앱 초기 진입 분기(AppGate), 온보딩 흐름, 스플래시 화면을 추가하여 첫 실행 경험을 개선
- 학교 목록 API 연동 및 학교 검색/선택 기능을 강화
- Firebase Analytics 기반 이벤트 수집 체계를 도입하고 이벤트 스키마 문서를 정리
- 홈/식단/날짜/위젯 동기화 관련 분석 이벤트를 추가하고, `selected_school_id` user property를 도입
- 설정 화면에서 기본 위젯 식당 변경 이벤트(`widget_default_cafeteria_change`)를 추가

### Changed
- 라우트 기반 네비게이션 구조로 전환하고 주요 화면 구조를 재정비
- 홈/학교선택/설정 화면 UI를 전면 개편하고 디자인 토큰(AppColors, AppTypography, AppShadow) 적용 범위를 확대
- 위젯 관련 UI/텍스트/클릭 라우팅/동기화 구조를 개선하여 동작 일관성과 가독성을 향상
- 식단 API 연동 로직을 학교 선택 기반으로 개선하고, API 응답 스키마 변경(`schools`)에 대응
- 공용 분석 파라미터 `env` 값을 flavor 기준(`dev`/`staging`/`prod`)으로 표준화
- 앱 버전을 `3.0.0+16`으로 상향

### Fixed
- 구버전 `selectedUniv` 데이터 필드 불일치로 앱 시작 시 발생하던 크래시 수정
- 학교 변경 후 이전 학교 캐시가 노출될 수 있던 문제 수정
- 학교 검색 관련 동작 오류를 수정하고 학교 선택 이후 검색 키워드가 초기화되도록 개선
- 위젯 업데이트 채널/성능/경로 처리 이슈를 보완하여 안정성 향상

### Removed
- `SCHEDULE_EXACT_ALARM` 권한 및 관련 설정 코드를 제거

## [1.1.0] - 2025-12-06

### Added
Expand Down
25 changes: 25 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# 🍚 밥묵자 앱 출시 노트 모음

## 버전 v3.0.0 (2026-03-02)
v3.0.0 업데이트: 개강 맞이, 밥묵자도 새 학기 모드로! 🚀

개강 시즌을 맞아 앱의 핵심 흐름을 처음부터 다시 다듬었습니다.

✨ 주요 변경 사항
- 첫 진입 경험 개선 (온보딩/스플래시/앱 분기)

- 학교 선택 및 식단 조회 정확도 강화
학교 선택 기능과 조회 로직을 개선해 선택한 학교 식단을 더 정확하게 보여주고, 학교 변경 후 이전 데이터 노출 문제를 해결했습니다.

- 홈/설정/학교선택 화면 UI 개편
주요 화면 가독성을 높이고 버튼/타이포/색상 등 디자인 시스템을 정리했습니다.

- 위젯 안정화 및 사용성 향상
위젯 동기화와 클릭 동작을 개선해 홈 화면에서 더 안정적으로 식단을 확인할 수 있습니다.

- 서비스 품질 개선을 위한 분석 체계 고도화
이벤트 수집 체계를 정비해 사용 흐름을 더 정확히 파악하고 개선 속도를 높였습니다.

항상 '밥묵자'를 사용해주셔서 감사합니다.
더 편리하고 믿을 수 있는 식단 앱이 되도록 계속 개선하겠습니다! 🍚

---

## 버전 v1.1.0 (2025-12-06)
v1.1.0 업데이트: 새 옷을 입은 밥묵자! 🎨

Expand Down
34 changes: 29 additions & 5 deletions docs/analytics/event_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
## 1) 문서 메타

- 문서 목적: BobMoo 앱의 Firebase Analytics 이벤트 수집 규칙과 이벤트 스키마를 표준화한다.
- 문서 버전: `v0.6`
- 문서 버전: `v0.8`
- 작성일: `2026-03-01`
- 오너: 밥묵자 안드로이드 개발팀
- 상태: 초안 (Draft)
- 변경 이력:
- `v0.8` (`2026-03-02`): `env` 공용 파라미터 값을 flavor 기준(`dev`/`staging`/`prod`)으로 변경
- `v0.7` (`2026-03-02`): `selected_school_id` user property 규칙 추가, `widget_default_cafeteria_change` 이벤트 추가
- `v0.6` (`2026-03-01`): `data_source` / `trigger_source` 파라미터 추가 및 foreground/background 구분 규칙 명시
- `v0.5` (`2026-03-01`): `meal_api_request.request_type`에 `retry` 추가
- `v0.4` (`2026-03-01`): `app_gate_decision` 파라미터명 `target_route` -> `destination_route`로 명확화
Expand Down Expand Up @@ -51,7 +53,7 @@
- `screen_view`는 `FirebaseAnalyticsObserver`로 자동 수집하는 것을 기본으로 한다.
- 자동 수집이 누락되는 사용자 액션(버튼 탭, 조회 시도, 결과 상태)은 커스텀 이벤트로 보완한다.
- 앱 시작 시 `setDefaultEventParameters`를 통해 `env`를 공용 파라미터로 주입한다.
- 값 규칙: `prod`(release 빌드), `dev`(debug/profile 빌드)
- 값 규칙: flavor 기준 `dev` / `staging` / `prod`

### 3.5 발화 규칙(중복 방지)

Expand All @@ -61,6 +63,15 @@
- `meal_error_state_view`: `school_id + meal_date + error_type`
- 날짜가 변경되면 중복 방지 키를 초기화하고 새 날짜 컨텍스트에서 다시 발화할 수 있다.

### 3.6 사용자 속성(User Property) 규칙

- `selected_school_id` user property를 사용해 현재 선택 학교 컨텍스트를 저장한다.
- 값은 `University.schoolId`를 문자열로 변환해 저장한다. (예: `"1"`)
- 갱신 시점:
- 앱 시작 시 로컬에 저장된 선택 학교 복원 직후
- 학교 선택/변경 확정 직후
- 선택 학교가 없는 상태에서는 `selected_school_id`를 `null`로 설정해 초기화한다.

## 4) 라우트-스크린 매핑 표준

현재 `main.dart` 라우트 기준:
Expand All @@ -82,12 +93,12 @@
| `screen_name` | string | N | 이벤트 발생 시점 화면명 (화면 문맥이 있을 때) |
| `route_name` | string | N | 라우트명 (`/home` 등) |
| `app_version` | string | N | 앱 버전 |
| `env` | string | Y | 실행 환경 (`prod` / `dev`) |
| `env` | string | Y | 실행 환경 (`dev` / `staging` / `prod`) |

안드로이드 단일 플랫폼 운영 기준으로 `platform` 공통 파라미터는 현재 사용하지 않는다.
멀티 플랫폼(iOS/Web) 확장 시 재도입을 검토한다.

## 6) 이벤트 카탈로그 (v0.6)
## 6) 이벤트 카탈로그 (v0.7)

### 6.1 `screen_view`

Expand All @@ -98,6 +109,7 @@
- 예: `/home` -> `home_screen`, `/select_school` -> `select_school_screen`
- 자동 수집 이벤트는 GA4 기본 필드(`screen_name`, `screen_class`)를 우선 사용한다.
- `route_name`은 자동 `screen_view`의 필수 필드가 아니며, 필요 시 별도 커스텀 이벤트에서 사용한다.
- 학교 컨텍스트 분석은 이벤트 파라미터가 아닌 user property `selected_school_id`를 기준으로 결합한다.

### 6.2 `app_gate_decision`

Expand Down Expand Up @@ -221,6 +233,16 @@
- `previous_error_type` (string, required) - `network_error` / `unknown_error`
- `screen_name` (string, required) - `home_screen`

### 6.14 `widget_default_cafeteria_change`

- 목적: 사용자의 기본 위젯 식당 선호 변경 패턴 분석
- 트리거: 설정 화면에서 기본 위젯 식당 변경 저장 완료 시점
- 파라미터
- `school_id` (int, optional) - 선택 학교가 있을 때만
- `previous_cafeteria` (string, optional) - 이전 선택 식당
- `new_cafeteria` (string, required) - 새 선택 식당
- `screen_name` (string, required) - `settings_screen`

## 7) 이벤트별 분석 질문

- `app_gate_decision`: 첫 진입 사용자가 온보딩으로 얼마나 이동하는가?
Expand All @@ -235,16 +257,18 @@
- `meal_empty_state_view`: 특정 학교/날짜에서 빈 데이터 노출이 반복되는가?
- `meal_error_state_view`: 사용자 체감 에러가 어떤 유형으로 집중되는가?
- `meal_retry_tap`: 에러 이후 재시도 전환률은 어느 정도인가?
- `widget_default_cafeteria_change`: 기본 위젯 식당은 어떤 학교/사용자군에서 어떻게 바뀌는가?

## 8) 구현 체크리스트

- [ ] `FirebaseAnalyticsObserver` 연결 및 `screen_view` 검증
- [ ] `AppGate` 분기 이벤트 추가 (`app_gate_decision`)
- [ ] 학교 선택/변경 이벤트 추가 (`select_school`, `change_school`)
- [ ] 식단 조회/요청 이벤트 추가 (`view_meal`, `meal_api_request`)
- [ ] 위젯 동기화 이벤트 추가 (`widget_sync`)
- [ ] 위젯 이벤트 추가 (`widget_sync`, `widget_default_cafeteria_change`)
- [ ] 학교 목록/검색 상호작용 이벤트 추가 (`school_list_load_result`, `school_search_result_tap`)
- [ ] 날짜 변경 및 상태 노출 이벤트 추가 (`date_change`, `meal_empty_state_view`, `meal_error_state_view`, `meal_retry_tap`)
- [ ] `selected_school_id` user property 갱신 로직 추가 (초기 복원/학교 변경 시점)
- [ ] DebugView에서 이벤트명/파라미터 유입 확인

## 9) 운영 메모
Expand Down
1 change: 1 addition & 0 deletions lib/locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Future<void> setupLocator() async {
() => MealRepository(
isar: locator<Isar>(),
menuService: locator<MenuService>(),
prefs: locator<SharedPreferences>(),
),
);
}
1 change: 1 addition & 0 deletions lib/providers/search_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class SearchProvider extends ChangeNotifier {
}

void updateKeyword(String keyword) {
if (_keyword == keyword) return;
_keyword = keyword;
notifyListeners();
}
Expand Down
9 changes: 9 additions & 0 deletions lib/providers/univ_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';

import 'package:bobmoo/locator.dart';
import 'package:bobmoo/models/university.dart';
import 'package:bobmoo/services/analytics_service.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand Down Expand Up @@ -36,6 +37,10 @@ class UnivProvider extends ChangeNotifier {
}
}

await AnalyticsService.instance.setSelectedSchoolUserProperty(
_selectedUniversity?.schoolId,
);

_isInitialized = true;
notifyListeners();
}
Expand All @@ -57,6 +62,10 @@ class UnivProvider extends ChangeNotifier {
await prefs.remove('selectedUniv');
}

await AnalyticsService.instance.setSelectedSchoolUserProperty(
_selectedUniversity?.schoolId,
);

notifyListeners();
}

Expand Down
81 changes: 75 additions & 6 deletions lib/repositories/meal_repository.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:bobmoo/collections/meal_collection.dart';
import 'package:bobmoo/collections/menu_cache_status.dart';
import 'package:bobmoo/collections/restaurant_collection.dart';
Expand All @@ -6,6 +8,7 @@ import 'package:bobmoo/services/menu_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:isar_community/isar.dart';
import 'package:shared_preferences/shared_preferences.dart';

// --- Custom Exceptions ---
/// 네트워크 오류를 위한 Exception
Expand Down Expand Up @@ -48,10 +51,14 @@ class MealFetchResult {
class MealRepository {
final Isar isar;
final MenuService menuService;
final SharedPreferences prefs;
static const String _fallbackSchoolNameK = '인하대학교';
static const String _lastFetchedSchoolNameKKey = 'lastFetchedSchoolNameK';

MealRepository({
required this.isar,
required this.menuService,
required this.prefs,
});

/// 핵심 함수: 특정 날짜의 식단 데이터를 가져옴
Expand All @@ -63,6 +70,10 @@ class MealRepository {
/// 핵심 함수(분석용): 특정 날짜 식단과 데이터 출처를 함께 반환
Future<MealFetchResult> getMealsForDateWithSource(DateTime date) async {
final targetDate = DateUtils.dateOnly(date);
final schoolNameK = _resolveSchoolNameK();
final lastFetchedSchoolNameK = prefs.getString(_lastFetchedSchoolNameKKey);
final isSchoolChanged =
lastFetchedSchoolNameK == null || lastFetchedSchoolNameK != schoolNameK;

// 1. 해당 날짜의 캐시 상태 확인
final cacheStatus = await isar.menuCacheStatuses
Expand All @@ -73,14 +84,25 @@ class MealRepository {
final bool isCacheStale =
cacheStatus == null ||
DateTime.now().difference(cacheStatus.lastFetchedAt).inHours >= 24;
final shouldFetchFromApi = isCacheStale || isSchoolChanged;

if (isCacheStale) {
if (shouldFetchFromApi) {
// 2a. 캐시가 없거나 오래되었으면 API 호출
if (kDebugMode) {
print("ℹ️ [Cache Miss/Stale] API를 호출하여 데이터를 갱신합니다: $targetDate");
print(
"ℹ️ [Cache Miss/Stale/SchoolChanged] API를 호출하여 데이터를 갱신합니다: $targetDate, school=$schoolNameK",
);
}
try {
final meals = await _fetchFromApiAndSave(targetDate);
if (isSchoolChanged) {
// 학교 변경 시 이전 학교 캐시가 노출되지 않도록 로컬 캐시를 먼저 비웁니다.
await _clearAllMealCaches();
}
final meals = await _fetchFromApiAndSave(
targetDate,
schoolNameK: schoolNameK,
);
await prefs.setString(_lastFetchedSchoolNameKKey, schoolNameK);
return MealFetchResult(
meals: meals,
dataSource: MealDataSource.apiFetched,
Expand All @@ -89,6 +111,11 @@ class MealRepository {
if (kDebugMode) {
print("🚨 [API Error] API 호출 실패: $e");
}
// 학교 변경 직후에는 이전 학교 stale 데이터가 섞일 수 있어 fallback을 사용하지 않습니다.
if (isSchoolChanged) {
rethrow;
}

// API 호출 실패 시, DB에 오래된 데이터라도 있는지 확인 후 반환
final staleData = await fetchFromDb(targetDate);
if (staleData.isNotEmpty) {
Expand All @@ -113,10 +140,16 @@ class MealRepository {
/// UI에서 Pull-to-Refresh(당겨서 새로고침)를 위한 함수
Future<List<Meal>> forceRefreshMeals(DateTime date) async {
final targetDate = DateUtils.dateOnly(date);
final schoolNameK = _resolveSchoolNameK();
if (kDebugMode) {
print("🔄 [Force Refresh] 강제로 데이터를 새로고침합니다: $targetDate");
}
return await _fetchFromApiAndSave(targetDate);
final meals = await _fetchFromApiAndSave(
targetDate,
schoolNameK: schoolNameK,
);
await prefs.setString(_lastFetchedSchoolNameKKey, schoolNameK);
return meals;
}

// --- Private Helper Methods ---
Expand All @@ -131,9 +164,15 @@ class MealRepository {
return meals;
}

Future<List<Meal>> _fetchFromApiAndSave(DateTime date) async {
Future<List<Meal>> _fetchFromApiAndSave(
DateTime date, {
required String schoolNameK,
}) async {
// 1. API에서 데이터 가져오기
final menuResponse = await menuService.getMenu(date);
final menuResponse = await menuService.getMenu(
date,
schoolNameK: schoolNameK,
);

// 2. DB에 저장
await _saveMenuResponseToDb(menuResponse);
Expand All @@ -142,6 +181,36 @@ class MealRepository {
return fetchFromDb(date);
}

String _resolveSchoolNameK() {
try {
final jsonString = prefs.getString('selectedUniv');
if (jsonString == null || jsonString.isEmpty) {
return _fallbackSchoolNameK;
}

final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return _fallbackSchoolNameK;
}

final schoolNameK = decoded['schoolNameK'];
if (schoolNameK is String && schoolNameK.isNotEmpty) {
return schoolNameK;
}
} catch (_) {
// 파싱 실패 시에는 기본 학교로 폴백하여 API 호출 실패를 방지합니다.
}

return _fallbackSchoolNameK;
}

Future<void> _clearAllMealCaches() async {
await isar.writeTxn(() async {
await isar.meals.clear();
await isar.menuCacheStatuses.clear();
});
}

/// response 응답을 DB에 추가
Future<void> _saveMenuResponseToDb(MenuResponse response) async {
final responseDate = DateUtils.dateOnly(DateTime.parse(response.date));
Expand Down
4 changes: 4 additions & 0 deletions lib/screens/select_school_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ class _SelectSchoolScreenState extends State<SelectSchoolScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<SearchProvider>().updateKeyword('');
});
_selectedUniv = context.read<UnivProvider>().selectedUniversity;
_initialSelectedUniv = _selectedUniv;
}
Expand Down
Loading