diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6066d..ae93f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 884de40..9cd06c3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,30 @@ # 🍚 밥묵자 앱 출시 노트 모음 +## 버전 v3.0.0 (2026-03-02) +v3.0.0 업데이트: 개강 맞이, 밥묵자도 새 학기 모드로! 🚀 + +개강 시즌을 맞아 앱의 핵심 흐름을 처음부터 다시 다듬었습니다. + +✨ 주요 변경 사항 +- 첫 진입 경험 개선 (온보딩/스플래시/앱 분기) + +- 학교 선택 및 식단 조회 정확도 강화 + 학교 선택 기능과 조회 로직을 개선해 선택한 학교 식단을 더 정확하게 보여주고, 학교 변경 후 이전 데이터 노출 문제를 해결했습니다. + +- 홈/설정/학교선택 화면 UI 개편 + 주요 화면 가독성을 높이고 버튼/타이포/색상 등 디자인 시스템을 정리했습니다. + +- 위젯 안정화 및 사용성 향상 + 위젯 동기화와 클릭 동작을 개선해 홈 화면에서 더 안정적으로 식단을 확인할 수 있습니다. + +- 서비스 품질 개선을 위한 분석 체계 고도화 + 이벤트 수집 체계를 정비해 사용 흐름을 더 정확히 파악하고 개선 속도를 높였습니다. + +항상 '밥묵자'를 사용해주셔서 감사합니다. +더 편리하고 믿을 수 있는 식단 앱이 되도록 계속 개선하겠습니다! 🍚 + +--- + ## 버전 v1.1.0 (2025-12-06) v1.1.0 업데이트: 새 옷을 입은 밥묵자! 🎨 diff --git a/docs/analytics/event_schema.md b/docs/analytics/event_schema.md index 2172951..40ddde3 100644 --- a/docs/analytics/event_schema.md +++ b/docs/analytics/event_schema.md @@ -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`로 명확화 @@ -51,7 +53,7 @@ - `screen_view`는 `FirebaseAnalyticsObserver`로 자동 수집하는 것을 기본으로 한다. - 자동 수집이 누락되는 사용자 액션(버튼 탭, 조회 시도, 결과 상태)은 커스텀 이벤트로 보완한다. - 앱 시작 시 `setDefaultEventParameters`를 통해 `env`를 공용 파라미터로 주입한다. - - 값 규칙: `prod`(release 빌드), `dev`(debug/profile 빌드) + - 값 규칙: flavor 기준 `dev` / `staging` / `prod` ### 3.5 발화 규칙(중복 방지) @@ -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` 라우트 기준: @@ -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` @@ -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` @@ -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`: 첫 진입 사용자가 온보딩으로 얼마나 이동하는가? @@ -235,6 +257,7 @@ - `meal_empty_state_view`: 특정 학교/날짜에서 빈 데이터 노출이 반복되는가? - `meal_error_state_view`: 사용자 체감 에러가 어떤 유형으로 집중되는가? - `meal_retry_tap`: 에러 이후 재시도 전환률은 어느 정도인가? +- `widget_default_cafeteria_change`: 기본 위젯 식당은 어떤 학교/사용자군에서 어떻게 바뀌는가? ## 8) 구현 체크리스트 @@ -242,9 +265,10 @@ - [ ] `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) 운영 메모 diff --git a/lib/locator.dart b/lib/locator.dart index 7aeab28..8a7b7ea 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -39,6 +39,7 @@ Future setupLocator() async { () => MealRepository( isar: locator(), menuService: locator(), + prefs: locator(), ), ); } diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart index 8647b45..0c753cb 100644 --- a/lib/providers/search_provider.dart +++ b/lib/providers/search_provider.dart @@ -56,6 +56,7 @@ class SearchProvider extends ChangeNotifier { } void updateKeyword(String keyword) { + if (_keyword == keyword) return; _keyword = keyword; notifyListeners(); } diff --git a/lib/providers/univ_provider.dart b/lib/providers/univ_provider.dart index cd5ab9f..18b7209 100644 --- a/lib/providers/univ_provider.dart +++ b/lib/providers/univ_provider.dart @@ -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'; @@ -36,6 +37,10 @@ class UnivProvider extends ChangeNotifier { } } + await AnalyticsService.instance.setSelectedSchoolUserProperty( + _selectedUniversity?.schoolId, + ); + _isInitialized = true; notifyListeners(); } @@ -57,6 +62,10 @@ class UnivProvider extends ChangeNotifier { await prefs.remove('selectedUniv'); } + await AnalyticsService.instance.setSelectedSchoolUserProperty( + _selectedUniversity?.schoolId, + ); + notifyListeners(); } diff --git a/lib/repositories/meal_repository.dart b/lib/repositories/meal_repository.dart index 4be2131..c7339ac 100644 --- a/lib/repositories/meal_repository.dart +++ b/lib/repositories/meal_repository.dart @@ -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'; @@ -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 @@ -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, }); /// 핵심 함수: 특정 날짜의 식단 데이터를 가져옴 @@ -63,6 +70,10 @@ class MealRepository { /// 핵심 함수(분석용): 특정 날짜 식단과 데이터 출처를 함께 반환 Future 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 @@ -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, @@ -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) { @@ -113,10 +140,16 @@ class MealRepository { /// UI에서 Pull-to-Refresh(당겨서 새로고침)를 위한 함수 Future> 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 --- @@ -131,9 +164,15 @@ class MealRepository { return meals; } - Future> _fetchFromApiAndSave(DateTime date) async { + Future> _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); @@ -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) { + return _fallbackSchoolNameK; + } + + final schoolNameK = decoded['schoolNameK']; + if (schoolNameK is String && schoolNameK.isNotEmpty) { + return schoolNameK; + } + } catch (_) { + // 파싱 실패 시에는 기본 학교로 폴백하여 API 호출 실패를 방지합니다. + } + + return _fallbackSchoolNameK; + } + + Future _clearAllMealCaches() async { + await isar.writeTxn(() async { + await isar.meals.clear(); + await isar.menuCacheStatuses.clear(); + }); + } + /// response 응답을 DB에 추가 Future _saveMenuResponseToDb(MenuResponse response) async { final responseDate = DateUtils.dateOnly(DateTime.parse(response.date)); diff --git a/lib/screens/select_school_screen.dart b/lib/screens/select_school_screen.dart index df85f74..a9c3f41 100644 --- a/lib/screens/select_school_screen.dart +++ b/lib/screens/select_school_screen.dart @@ -60,6 +60,10 @@ class _SelectSchoolScreenState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().updateKeyword(''); + }); _selectedUniv = context.read().selectedUniversity; _initialSelectedUniv = _selectedUniv; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8eb8196..4356289 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:bobmoo/ui/components/cards/setting_section_card.dart'; import 'package:bobmoo/ui/theme/app_colors.dart'; import 'package:bobmoo/models/university.dart'; import 'package:bobmoo/providers/univ_provider.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:bobmoo/services/widget_service.dart'; import 'package:flutter/material.dart'; @@ -77,6 +78,11 @@ class _SettingsScreenState extends State Future _saveSelectedCafeteria( String cafeteriaName, ) async { + final previousCafeteria = _selectedCafeteria; + if (previousCafeteria == cafeteriaName) { + return; + } + // SharedPreferences 대신 HomeWidget을 사용하여 데이터를 저장합니다. await HomeWidget.saveWidgetData( 'selectedCafeteriaName', @@ -93,6 +99,13 @@ class _SettingsScreenState extends State // async 함수에서 context를 사용할 때는 항상 mounted 여부를 확인하는 것이 안전합니다. if (!mounted) return; + final schoolId = context.read().selectedUniversity?.schoolId; + AnalyticsService.instance.logWidgetDefaultCafeteriaChange( + schoolId: schoolId, + previousCafeteria: previousCafeteria, + newCafeteria: cafeteriaName, + ); + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart index 94ddb7a..d94274e 100644 --- a/lib/services/analytics_service.dart +++ b/lib/services/analytics_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; enum AppGateDestinationRoute { home('/home'), @@ -97,7 +98,18 @@ class AnalyticsService { final FirebaseAnalytics _analytics = FirebaseAnalytics.instance; bool _isInitialized = false; - String get environment => kReleaseMode ? 'prod' : 'dev'; + String get environment { + switch (appFlavor) { + case 'prod': + return 'prod'; + case 'staging': + return 'staging'; + case 'dev': + return 'dev'; + default: + return kReleaseMode ? 'prod' : 'dev'; + } + } Future initialize() async { if (_isInitialized) return; @@ -322,6 +334,35 @@ class AnalyticsService { ); } + void logWidgetDefaultCafeteriaChange({ + int? schoolId, + String? previousCafeteria, + required String newCafeteria, + }) { + _logEvent( + name: 'widget_default_cafeteria_change', + parameters: { + 'school_id': schoolId, + 'previous_cafeteria': previousCafeteria, + 'new_cafeteria': newCafeteria, + 'screen_name': 'settings_screen', + }, + ); + } + + Future setSelectedSchoolUserProperty(int? schoolId) async { + try { + await _analytics.setUserProperty( + name: 'selected_school_id', + value: schoolId?.toString(), + ); + } catch (error) { + if (kDebugMode) { + debugPrint('[Analytics] setUserProperty failed: $error'); + } + } + } + void _logEvent({ required String name, required Map parameters, diff --git a/lib/services/menu_service.dart b/lib/services/menu_service.dart index 113ef3e..b3e9d80 100644 --- a/lib/services/menu_service.dart +++ b/lib/services/menu_service.dart @@ -7,14 +7,20 @@ class MenuService { final String _baseUrl = 'https://bobmoo.site/api/v1/menu'; // 날짜를 인자로 받아 해당 날짜의 메뉴를 가져오는 함수 - Future getMenu(DateTime date) async { + Future getMenu( + DateTime date, { + required String schoolNameK, + }) async { // 날짜를 'yyyy-MM-dd' 형식의 문자열로 변환 String formattedDate = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - // TODO: 나중에 학교 선택에 따라 달라지게 처리 (임시로 인하대학교로 고정) + final uri = Uri.parse( + _baseUrl, + ).replace(queryParameters: {'date': formattedDate, 'school': schoolNameK}); + final response = await http.get( - Uri.parse('$_baseUrl?date=$formattedDate&school=인하대학교'), + uri, ); if (response.statusCode == 200) { diff --git a/pubspec.yaml b/pubspec.yaml index 0101630..7a8d227 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: 'none' # 버전 설정 versionName+versionCode -version: 1.1.0+15 +version: 3.0.0+16 environment: sdk: ^3.9.2