diff --git a/docs/analytics/event_schema.md b/docs/analytics/event_schema.md new file mode 100644 index 0000000..2172951 --- /dev/null +++ b/docs/analytics/event_schema.md @@ -0,0 +1,254 @@ +# BobMoo Firebase Analytics 이벤트 스키마 + +## 1) 문서 메타 + +- 문서 목적: BobMoo 앱의 Firebase Analytics 이벤트 수집 규칙과 이벤트 스키마를 표준화한다. +- 문서 버전: `v0.6` +- 작성일: `2026-03-01` +- 오너: 밥묵자 안드로이드 개발팀 +- 상태: 초안 (Draft) +- 변경 이력: + - `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`로 명확화 + - `v0.3` (`2026-03-01`): `env` 공용 파라미터(dev/prod) 추가, 앱 시작 시 default event parameter 설정 규칙 추가 + - `v0.2` (`2026-03-01`): `date_change` 확장(`change_source`, `days_delta`), 추가 이벤트 6종 보강, 중복 발화 가드 규칙 추가 + - `v0.1` (`2026-03-01`): 초기 이벤트 스키마 초안 작성 + +## 2) 목표 + +- 사용자 행동 데이터를 바탕으로 기능 사용률을 분석한다. +- `screen_view` 기반 퍼널 분석이 가능하도록 라우팅/스크린 명칭을 통일한다. +- 학교 선택/변경, 식단 조회, 위젯 상호작용 패턴을 계량화한다. + +## 3) 전역 규칙 + +### 3.1 이벤트/파라미터 네이밍 + +- 이벤트명: `snake_case` 사용 (`select_school`, `view_meal`). +- 파라미터명: `snake_case` 사용 (`school_id`, `entry_point`). +- 스크린명: `_screen` 규칙 사용 (`home_screen`, `select_school_screen`). + +### 3.2 데이터 타입 규칙 + +- `school_id`는 API에서 내려오는 `University.schoolId` 값을 그대로 사용한다. +- 현재 앱 모델 기준 `school_id` 타입은 `int`다. +- 날짜는 `yyyy-MM-dd` 문자열로 통일한다. +- 불리언은 `true/false`로 저장한다. +- 데이터 출처는 `data_source`로 표준화한다. + - `db_hit` / `api_fetched` / `db_stale_fallback` +- 실행 컨텍스트는 `trigger_source`로 표준화한다. + - `foreground` / `background_workmanager` + +### 3.3 식별자 및 개인정보(PII) 규칙 + +- `school_id`는 클라이언트에서 임의 생성/변환하지 않는다. +- 학교명 원문(`schoolName`, `schoolNameK`)은 이벤트 파라미터로 전송하지 않는다. +- 사용자 직접 식별 가능한 정보(이메일, 전화번호, 실명)는 전송하지 않는다. + +### 3.4 수집 방식 규칙 + +- `screen_view`는 `FirebaseAnalyticsObserver`로 자동 수집하는 것을 기본으로 한다. +- 자동 수집이 누락되는 사용자 액션(버튼 탭, 조회 시도, 결과 상태)은 커스텀 이벤트로 보완한다. +- 앱 시작 시 `setDefaultEventParameters`를 통해 `env`를 공용 파라미터로 주입한다. + - 값 규칙: `prod`(release 빌드), `dev`(debug/profile 빌드) + +### 3.5 발화 규칙(중복 방지) + +- 상태 노출 이벤트(`meal_empty_state_view`, `meal_error_state_view`)는 화면 리빌드마다 발화하지 않는다. +- 동일 컨텍스트 기준으로 화면 진입당 1회만 발화한다. + - `meal_empty_state_view`: `school_id + meal_date` + - `meal_error_state_view`: `school_id + meal_date + error_type` +- 날짜가 변경되면 중복 방지 키를 초기화하고 새 날짜 컨텍스트에서 다시 발화할 수 있다. + +## 4) 라우트-스크린 매핑 표준 + +현재 `main.dart` 라우트 기준: + +| route_name | screen_name | 비고 | +|---|---|---| +| `/` | `app_gate_screen` | 시작 진입 및 분기 판단 | +| `/onboarding` | `onboarding_screen` | 초기 온보딩 | +| `/select_school` | `select_school_screen` | 학교 선택 | +| `/home` | `home_screen` | 메인 식단 화면 | +| `/settings` | `settings_screen` | 설정 화면 | + +## 5) 공통 파라미터 + +아래 파라미터는 화면 문맥이 있는 이벤트에 공통으로 넣는다. + +| param | type | required | 설명 | +|---|---|---|---| +| `screen_name` | string | N | 이벤트 발생 시점 화면명 (화면 문맥이 있을 때) | +| `route_name` | string | N | 라우트명 (`/home` 등) | +| `app_version` | string | N | 앱 버전 | +| `env` | string | Y | 실행 환경 (`prod` / `dev`) | + +안드로이드 단일 플랫폼 운영 기준으로 `platform` 공통 파라미터는 현재 사용하지 않는다. +멀티 플랫폼(iOS/Web) 확장 시 재도입을 검토한다. + +## 6) 이벤트 카탈로그 (v0.6) + +### 6.1 `screen_view` + +- 목적: 화면 전환 기반 퍼널 분석 +- 수집 방식: 자동 (`FirebaseAnalyticsObserver`) +- 참고: + - `FirebaseAnalyticsObserver.nameExtractor`를 사용해 라우트명을 스키마 표준 `screen_name`으로 매핑한다. + - 예: `/home` -> `home_screen`, `/select_school` -> `select_school_screen` + - 자동 수집 이벤트는 GA4 기본 필드(`screen_name`, `screen_class`)를 우선 사용한다. + - `route_name`은 자동 `screen_view`의 필수 필드가 아니며, 필요 시 별도 커스텀 이벤트에서 사용한다. + +### 6.2 `app_gate_decision` + +- 목적: 앱 시작 시 진입 분기 비율 확인 (`home` vs `onboarding`) +- 트리거: `AppGate`에서 초기 분기 결정 시점 +- 파라미터 + - `destination_route` (string, required) - `/home` 또는 `/onboarding` + - `has_selected_school` (bool, required) + +### 6.3 `select_school` + +- 목적: 학교 선택 완료율/진입경로 분석 +- 트리거: 학교 선택 화면에서 "선택완료" 탭 후 확정 시점 +- 파라미터 + - `school_id` (int, required) - `University.schoolId` + - `entry_point` (string, required) - `onboarding` / `settings` + - `is_first_select` (bool, required) + +### 6.4 `change_school` + +- 목적: 기존 사용자 학교 변경 빈도 분석 +- 트리거: 기존 선택 학교가 있는 상태에서 다른 학교로 변경 확정 시점 +- 파라미터 + - `previous_school_id` (int, required) + - `new_school_id` (int, required) + - `entry_point` (string, required) - 기본 `settings` + +### 6.5 `view_meal` + +- 목적: 식단 조회 패턴(날짜/오프셋) 분석 +- 트리거: 식단 데이터 로딩 성공 후 화면 렌더 가능한 시점 +- 파라미터 + - `school_id` (int, required) + - `meal_date` (string, required) - `yyyy-MM-dd` + - `date_offset` (int, required) - 오늘 기준 일수 차이 (`0`, `-1`, `+1`) + - `meal_count` (int, optional) - 조회된 식단 개수 + - `data_source` (string, optional) - `db_hit` / `api_fetched` / `db_stale_fallback` + +### 6.6 `meal_api_request` + +- 목적: 날짜별 식단 API 요청량/실패율 분석 +- 트리거: 식단 API 요청 직후 +- 파라미터 + - `school_id` (int, required) + - `meal_date` (string, required) + - `request_type` (string, required) - `initial_load` / `retry` / `user_pull_to_refresh` / `date_change` + - `change_source` (string, optional) - `request_type=date_change`일 때만, `swipe` / `picker` + - `data_source` (string, optional) - `db_hit` / `api_fetched` / `db_stale_fallback` + - `trigger_source` (string, required) - `foreground` / `background_workmanager` + - `result` (string, required) - `success` / `network_error` / `stale_data` / `unknown_error` + +### 6.7 `widget_sync` + +- 목적: 위젯 데이터 동기화 성공/실패 패턴 분석 +- 트리거: 위젯 저장 작업 완료 시점 +- 파라미터 + - `school_id` (int, optional) - 선택 학교가 있을 때만 + - `cafeteria_count` (int, optional) + - `result` (string, required) - `success` / `failure` / `skipped_in_progress` / `skipped_debounce` + - `trigger_source` (string, required) - `foreground` / `background_workmanager` + +### 6.8 `school_list_load_result` + +- 목적: 학교 목록 로딩 성능/실패가 선택 퍼널 이탈에 미치는 영향 분석 +- 트리거: 학교 목록 초기 로딩 완료 시점 +- 파라미터 + - `result` (string, required) - `success` / `failure` + - `school_count` (int, optional) - `result=success`일 때만 + - `load_time_ms` (int, optional) + - `screen_name` (string, required) - 기본 `select_school_screen` + +### 6.9 `school_search_result_tap` + +- 목적: 학교 검색 UX 품질(검색 결과 순위별 선택 패턴) 분석 +- 트리거: 검색 결과 리스트에서 학교 항목 탭 시점 +- 파라미터 + - `school_id` (int, required) + - `result_rank` (int, required) - 검색 결과 내 1-based 순위 + - `entry_point` (string, required) - `onboarding` / `settings` + - `screen_name` (string, required) - `select_school_screen` + +### 6.10 `date_change` + +- 목적: 오늘 외 날짜 탐색 수요와 탐색 패턴 분석 +- 트리거: 날짜 변경 액션 이후 실제 `meal_date`가 이전과 달라진 경우에만 발화 +- 파라미터 + - `school_id` (int, required) + - `previous_date` (string, required) - 변경 전 날짜 `yyyy-MM-dd` + - `meal_date` (string, required) - 변경 후 날짜 `yyyy-MM-dd` + - `date_offset` (int, required) - 변경 후 날짜의 오늘 기준 일수 차이 + - `change_source` (string, required) - `swipe` / `picker` + - `days_delta` (int, required) - `meal_date - previous_date` 일수 차이 (예: `-1`, `+1`) + +### 6.11 `meal_empty_state_view` + +- 목적: 식단 데이터 공백 노출 빈도 분석 +- 트리거: 식단 화면에서 빈 상태 UI가 최초 노출되는 시점 (동일 `school_id + meal_date` 기준 1회) +- 파라미터 + - `school_id` (int, required) + - `meal_date` (string, required) + - `date_offset` (int, required) + - `screen_name` (string, required) - `home_screen` + +### 6.12 `meal_error_state_view` + +- 목적: 사용자 체감 오류율(네트워크/기타) 분석 +- 트리거: 식단 화면에서 에러 UI가 최초 노출되는 시점 (동일 `school_id + meal_date + error_type` 기준 1회) +- 파라미터 + - `school_id` (int, required) + - `meal_date` (string, required) + - `date_offset` (int, required) + - `error_type` (string, required) - `network_error` / `unknown_error` + +### 6.13 `meal_retry_tap` + +- 목적: 오류 노출 이후 재시도 행동과 복구 의지 분석 +- 트리거: 에러 상태에서 "다시 시도" 버튼 탭 시점 +- 파라미터 + - `school_id` (int, required) + - `meal_date` (string, required) + - `previous_error_type` (string, required) - `network_error` / `unknown_error` + - `screen_name` (string, required) - `home_screen` + +## 7) 이벤트별 분석 질문 + +- `app_gate_decision`: 첫 진입 사용자가 온보딩으로 얼마나 이동하는가? +- `select_school`: 학교 선택 완료율은 얼마이며 어디서 이탈하는가? +- `change_school`: 학교 변경은 얼마나 자주 발생하는가? +- `view_meal`: 어떤 날짜/요일 조회가 많은가? +- `meal_api_request`: 실패가 특정 날짜/요청 유형에 집중되는가? +- `widget_sync`: 위젯 동기화 실패/스킵 비율은 어느 정도인가? +- `school_list_load_result`: 학교 목록 로딩 실패/지연이 선택 이탈에 영향을 주는가? +- `school_search_result_tap`: 검색 결과 상단 노출이 실제 선택으로 이어지는가? +- `date_change`: 사용자는 오늘 외 날짜를 얼마나 자주 탐색하는가? +- `meal_empty_state_view`: 특정 학교/날짜에서 빈 데이터 노출이 반복되는가? +- `meal_error_state_view`: 사용자 체감 에러가 어떤 유형으로 집중되는가? +- `meal_retry_tap`: 에러 이후 재시도 전환률은 어느 정도인가? + +## 8) 구현 체크리스트 + +- [ ] `FirebaseAnalyticsObserver` 연결 및 `screen_view` 검증 +- [ ] `AppGate` 분기 이벤트 추가 (`app_gate_decision`) +- [ ] 학교 선택/변경 이벤트 추가 (`select_school`, `change_school`) +- [ ] 식단 조회/요청 이벤트 추가 (`view_meal`, `meal_api_request`) +- [ ] 위젯 동기화 이벤트 추가 (`widget_sync`) +- [ ] 학교 목록/검색 상호작용 이벤트 추가 (`school_list_load_result`, `school_search_result_tap`) +- [ ] 날짜 변경 및 상태 노출 이벤트 추가 (`date_change`, `meal_empty_state_view`, `meal_error_state_view`, `meal_retry_tap`) +- [ ] DebugView에서 이벤트명/파라미터 유입 확인 + +## 9) 운영 메모 + +- 이벤트 스키마 변경 시 문서 버전을 올리고 변경 이력을 남긴다. +- 기존 파라미터 이름 변경 대신 신규 파라미터 추가를 우선 검토한다. +- 대시보드/쿼리는 본 문서의 이벤트명/파라미터명을 단일 소스로 사용한다. diff --git a/lib/main.dart b/lib/main.dart index e976b6b..592d6c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,7 +9,9 @@ import 'package:bobmoo/screens/home_screen.dart'; import 'package:bobmoo/screens/onboarding_screen.dart'; import 'package:bobmoo/screens/select_school_screen.dart'; import 'package:bobmoo/screens/settings_screen.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:bobmoo/services/background_service.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -25,6 +27,7 @@ void main() async { // Firebase 초기화 await Firebase.initializeApp(); + await AnalyticsService.instance.initialize(); // 한국어 로케일데이터 추가 await initializeDateFormatting('ko_KR', null); @@ -64,6 +67,48 @@ void main() async { class BobMooApp extends StatelessWidget { const BobMooApp({super.key}); + static final FirebaseAnalyticsObserver _analyticsObserver = + FirebaseAnalyticsObserver( + analytics: FirebaseAnalytics.instance, + nameExtractor: (settings) { + final rawRouteName = settings.name; + final isWidgetCallbackRoute = () { + if (rawRouteName == null || rawRouteName.isEmpty) { + return true; + } + if (rawRouteName.startsWith('/CALLBACK')) { + return true; + } + + final uri = Uri.tryParse(rawRouteName); + if ((uri?.path ?? '').toUpperCase().startsWith('/CALLBACK')) { + return true; + } + + return rawRouteName.toUpperCase().contains(':/CALLBACK'); + }(); + + final normalizedRouteName = isWidgetCallbackRoute + ? '/' + : rawRouteName; + + switch (normalizedRouteName) { + case '/': + return 'app_gate_screen'; + case '/onboarding': + return 'onboarding_screen'; + case '/select_school': + return 'select_school_screen'; + case '/home': + return 'home_screen'; + case '/settings': + return 'settings_screen'; + default: + return normalizedRouteName ?? 'unknown_screen'; + } + }, + ); + @override Widget build(BuildContext context) { return ScreenUtilInit( @@ -91,8 +136,9 @@ class BobMooApp extends StatelessWidget { // Glance 위젯 액션(e.g. glance-action:/CALLBACK?...) fallback return rawRouteName.toUpperCase().contains(':/CALLBACK'); }(); - final normalizedRouteName = - isWidgetCallbackRoute ? "/" : rawRouteName; + final normalizedRouteName = isWidgetCallbackRoute + ? "/" + : rawRouteName; if (kDebugMode) { debugPrint( @@ -116,11 +162,21 @@ class BobMooApp extends StatelessWidget { // 학교 선택 화면 라우트 case "/select_school": - // 여기만 “반환 타입”을 명시 - final bool allowBack = settings.arguments as bool; + final args = settings.arguments; + bool allowBack = false; + String entryPoint = 'onboarding'; + + if (args is Map) { + allowBack = args['allowBack'] as bool? ?? false; + entryPoint = args['entryPoint'] as String? ?? 'onboarding'; + } + return MaterialPageRoute( settings: settings, - builder: (_) => SelectSchoolScreen(allowBack: allowBack), + builder: (_) => SelectSchoolScreen( + allowBack: allowBack, + entryPoint: entryPoint, + ), ); // 홈화면 라우트 @@ -132,9 +188,27 @@ class BobMooApp extends StatelessWidget { // 설정화면 라우트 case "/settings": - return MaterialPageRoute( + return PageRouteBuilder( settings: settings, - builder: (_) => const SettingsScreen(), + pageBuilder: (context, animation, secondaryAnimation) => + const SettingsScreen(), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + const begin = Offset(1.0, 0.0); + const end = Offset.zero; + const curve = Curves.ease; + + final tween = Tween( + begin: begin, + end: end, + ).chain(CurveTween(curve: curve)); + final offsetAnimation = animation.drive(tween); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, ); // 잘못된 라우트 이름 @@ -154,6 +228,7 @@ class BobMooApp extends StatelessWidget { }, title: '밥묵자', theme: _getThemeData(), + navigatorObservers: [_analyticsObserver], locale: const Locale('ko', 'KR'), // 앱의 기본 언어를 한국어로 설정 localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, diff --git a/lib/providers/search_provider.dart b/lib/providers/search_provider.dart index 5a3ebf2..8647b45 100644 --- a/lib/providers/search_provider.dart +++ b/lib/providers/search_provider.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:bobmoo/models/university.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart' as http; @@ -14,10 +15,26 @@ class SearchProvider extends ChangeNotifier { // 1. 앱 시작 시 딱 한 번 호출해서 상태를 복원합니다. Future init() async { - _allItems = await _loadUniversities(); + final stopwatch = Stopwatch()..start(); - _isLoading = false; - notifyListeners(); + try { + _allItems = await _loadUniversities(); + AnalyticsService.instance.logSchoolListLoadResult( + result: SchoolListLoadResult.success, + schoolCount: _allItems.length, + loadTimeMs: stopwatch.elapsedMilliseconds, + ); + } catch (error) { + AnalyticsService.instance.logSchoolListLoadResult( + result: SchoolListLoadResult.failure, + loadTimeMs: stopwatch.elapsedMilliseconds, + ); + rethrow; + } finally { + stopwatch.stop(); + _isLoading = false; + notifyListeners(); + } } Future> _loadUniversities() async { diff --git a/lib/repositories/meal_repository.dart b/lib/repositories/meal_repository.dart index 2e1f646..4be2131 100644 --- a/lib/repositories/meal_repository.dart +++ b/lib/repositories/meal_repository.dart @@ -20,10 +20,28 @@ class NetworkException implements Exception { class StaleDataException implements Exception { final List staleData; final String message; + final MealDataSource dataSource; StaleDataException( this.staleData, { this.message = "오프라인 상태입니다. 마지막으로 저장된 정보를 표시합니다.", + this.dataSource = MealDataSource.dbStaleFallback, + }); +} + +enum MealDataSource { + dbHit, + apiFetched, + dbStaleFallback, +} + +class MealFetchResult { + final List meals; + final MealDataSource dataSource; + + MealFetchResult({ + required this.meals, + required this.dataSource, }); } @@ -38,6 +56,12 @@ class MealRepository { /// 핵심 함수: 특정 날짜의 식단 데이터를 가져옴 Future> getMealsForDate(DateTime date) async { + final result = await getMealsForDateWithSource(date); + return result.meals; + } + + /// 핵심 함수(분석용): 특정 날짜 식단과 데이터 출처를 함께 반환 + Future getMealsForDateWithSource(DateTime date) async { final targetDate = DateUtils.dateOnly(date); // 1. 해당 날짜의 캐시 상태 확인 @@ -56,7 +80,11 @@ class MealRepository { print("ℹ️ [Cache Miss/Stale] API를 호출하여 데이터를 갱신합니다: $targetDate"); } try { - return await _fetchFromApiAndSave(targetDate); + final meals = await _fetchFromApiAndSave(targetDate); + return MealFetchResult( + meals: meals, + dataSource: MealDataSource.apiFetched, + ); } catch (e) { if (kDebugMode) { print("🚨 [API Error] API 호출 실패: $e"); @@ -74,7 +102,11 @@ class MealRepository { if (kDebugMode) { print("✅ [Cache Hit] DB에서 신선한 데이터를 가져옵니다: $targetDate"); } - return await fetchFromDb(targetDate); + final meals = await fetchFromDb(targetDate); + return MealFetchResult( + meals: meals, + dataSource: MealDataSource.dbHit, + ); } } diff --git a/lib/screens/app_gate.dart b/lib/screens/app_gate.dart index cb1edf7..6f54fba 100644 --- a/lib/screens/app_gate.dart +++ b/lib/screens/app_gate.dart @@ -1,4 +1,5 @@ import 'package:bobmoo/providers/univ_provider.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:bobmoo/screens/loading_screen.dart'; import 'package:bobmoo/screens/splash_screen.dart'; import 'package:flutter/material.dart'; @@ -39,9 +40,15 @@ class _AppGateState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final targetRoute = (univProvider.selectedUniversity != null) - ? "/home" - : "/onboarding"; + final hasSelectedSchool = univProvider.selectedUniversity != null; + final targetRoute = hasSelectedSchool ? "/home" : "/onboarding"; + final analyticsDestinationRoute = hasSelectedSchool + ? AppGateDestinationRoute.home + : AppGateDestinationRoute.onboarding; + AnalyticsService.instance.logAppGateDecision( + destinationRoute: analyticsDestinationRoute, + hasSelectedSchool: hasSelectedSchool, + ); Navigator.of( context, diff --git a/lib/screens/home_analytics_helper.dart b/lib/screens/home_analytics_helper.dart new file mode 100644 index 0000000..b8a25d6 --- /dev/null +++ b/lib/screens/home_analytics_helper.dart @@ -0,0 +1,112 @@ +import 'package:bobmoo/repositories/meal_repository.dart'; +import 'package:bobmoo/services/analytics_service.dart'; +import 'package:intl/intl.dart'; + +class HomeAnalyticsHelper { + MealApiRequestType _nextMealRequestType = MealApiRequestType.initialLoad; + AnalyticsChangeSource? _nextMealChangeSource; + String? _lastEmptyStateKey; + final Set _loggedErrorStateKeys = {}; + + void setMealRequestContext({ + required MealApiRequestType requestType, + AnalyticsChangeSource? changeSource, + }) { + _nextMealRequestType = requestType; + _nextMealChangeSource = changeSource; + } + + ({MealApiRequestType requestType, AnalyticsChangeSource? changeSource}) + consumeMealRequestContext() { + final requestType = _nextMealRequestType; + final changeSource = _nextMealChangeSource; + _nextMealRequestType = MealApiRequestType.initialLoad; + _nextMealChangeSource = null; + return (requestType: requestType, changeSource: changeSource); + } + + String toDateKey(DateTime date) => DateFormat('yyyy-MM-dd').format(date); + + int dateOffsetFromToday(DateTime date) { + final today = DateTime.now(); + final onlyDate = DateTime(date.year, date.month, date.day); + final onlyToday = DateTime(today.year, today.month, today.day); + return onlyDate.difference(onlyToday).inDays; + } + + void resetStateExposureGuards() { + _lastEmptyStateKey = null; + _loggedErrorStateKeys.clear(); + } + + AnalyticsErrorType errorTypeOf(Object error) { + if (error is NetworkException) return AnalyticsErrorType.networkError; + return AnalyticsErrorType.unknownError; + } + + void logDateChangeIfNeeded({ + required int? schoolId, + required DateTime previousDate, + required DateTime nextDate, + required AnalyticsChangeSource changeSource, + }) { + if (schoolId == null) return; + if (_isSameDay(previousDate, nextDate)) return; + + AnalyticsService.instance.logDateChange( + schoolId: schoolId, + previousDate: toDateKey(previousDate), + mealDate: toDateKey(nextDate), + dateOffset: dateOffsetFromToday(nextDate), + changeSource: changeSource, + daysDelta: DateTime(nextDate.year, nextDate.month, nextDate.day) + .difference( + DateTime(previousDate.year, previousDate.month, previousDate.day), + ) + .inDays, + ); + } + + void logEmptyStateIfNeeded({ + required int? schoolId, + required DateTime selectedDate, + }) { + if (schoolId == null) return; + + final mealDate = toDateKey(selectedDate); + final contextKey = '$schoolId|$mealDate'; + if (_lastEmptyStateKey == contextKey) return; + + _lastEmptyStateKey = contextKey; + AnalyticsService.instance.logMealEmptyStateView( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: dateOffsetFromToday(selectedDate), + ); + } + + void logErrorStateIfNeeded({ + required int? schoolId, + required DateTime selectedDate, + required Object error, + }) { + if (schoolId == null) return; + + final mealDate = toDateKey(selectedDate); + final errorType = errorTypeOf(error); + final contextKey = '$schoolId|$mealDate|$errorType'; + if (_loggedErrorStateKeys.contains(contextKey)) return; + + _loggedErrorStateKeys.add(contextKey); + AnalyticsService.instance.logMealErrorStateView( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: dateOffsetFromToday(selectedDate), + errorType: errorType, + ); + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7e25b9d..71385c7 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,14 +3,12 @@ import 'dart:io'; import 'package:bobmoo/collections/meal_collection.dart'; import 'package:bobmoo/ui/theme/app_colors.dart'; import 'package:bobmoo/locator.dart'; -import 'package:bobmoo/models/all_cafeterias_widget_data.dart'; import 'package:bobmoo/models/meal_by_cafeteria.dart'; -import 'package:bobmoo/models/menu_model.dart'; import 'package:bobmoo/providers/univ_provider.dart'; import 'package:bobmoo/repositories/meal_repository.dart'; -import 'package:bobmoo/models/meal_widget_data.dart'; -import 'package:bobmoo/screens/settings_screen.dart'; -import 'package:bobmoo/services/widget_service.dart'; +import 'package:bobmoo/screens/home_analytics_helper.dart'; +import 'package:bobmoo/screens/home_widget_sync_helper.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:bobmoo/utils/meal_utils.dart'; import 'package:bobmoo/ui/components/cards/time_grouped_card.dart'; @@ -33,6 +31,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State with WidgetsBindingObserver { final MealRepository _repository = locator(); + late final HomeWidgetSyncHelper _widgetSyncHelper; late Future> _mealFuture; DateTime? _lastWidgetUpdateAt; static const Duration _widgetUpdateMinInterval = Duration(seconds: 30); @@ -47,11 +46,13 @@ class _HomeScreenState extends State with WidgetsBindingObserver { double _horizontalDragOffset = 0; bool _isHorizontalDragging = false; int _dateTransitionDirection = 1; // 1: 다음날(왼쪽 스와이프), -1: 이전날 + final HomeAnalyticsHelper _analyticsHelper = HomeAnalyticsHelper(); /// 화면이 처음 나타날 때 데이터 불러오기 @override void initState() { super.initState(); + _widgetSyncHelper = HomeWidgetSyncHelper(repository: _repository); // 앱 상태를 확인하기 위한 옵저버 할당 WidgetsBinding.instance.addObserver(this); // 앱 시작 시 업데이트 확인 @@ -118,8 +119,35 @@ class _HomeScreenState extends State with WidgetsBindingObserver { /// /// Repository에게 식단 데이터를 요청한다. Future> _fetchData() async { + final requestContext = _analyticsHelper.consumeMealRequestContext(); + final mealDate = _analyticsHelper.toDateKey(_selectedDate); + final schoolId = _currentSchoolId; + try { - final meals = await _repository.getMealsForDate(_selectedDate); + final fetchResult = await _repository.getMealsForDateWithSource( + _selectedDate, + ); + final meals = fetchResult.meals; + final dataSource = _toAnalyticsDataSource(fetchResult.dataSource); + + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: requestContext.requestType, + changeSource: requestContext.changeSource, + dataSource: dataSource, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.success, + ); + AnalyticsService.instance.logViewMeal( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: _analyticsHelper.dateOffsetFromToday(_selectedDate), + dataSource: dataSource, + mealCount: meals.length, + ); + } // 위젯은 "오늘" 데이터만 사용합니다. 오늘 데이터가 있으면 재조회 없이 재사용합니다. _updateWidgetOnly( @@ -129,14 +157,52 @@ class _HomeScreenState extends State with WidgetsBindingObserver { return meals; } catch (e) { if (e is StaleDataException) { + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: requestContext.requestType, + changeSource: requestContext.changeSource, + dataSource: _toAnalyticsDataSource(e.dataSource), + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.staleData, + ); + AnalyticsService.instance.logViewMeal( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: _analyticsHelper.dateOffsetFromToday(_selectedDate), + dataSource: _toAnalyticsDataSource(e.dataSource), + mealCount: e.staleData.length, + ); + } // 신선도가 떨어진 데이터를 취급하는 경우 _showStaleDataSnackbar(e); // 데이터를 반환하여 화면은 정상적으로 그리도록 함 return e.staleData; } else if (e is SocketException) { + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: requestContext.requestType, + changeSource: requestContext.changeSource, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.networkError, + ); + } // 네트워크 연결이 없는경우 throw NetworkException(); } + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: requestContext.requestType, + changeSource: requestContext.changeSource, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.unknownError, + ); + } // 다른 모든 에러는 FutureBuilder로 전달 rethrow; } @@ -144,7 +210,13 @@ class _HomeScreenState extends State with WidgetsBindingObserver { /// 위젯 데이터 업데이트 함수 (오늘날짜) Future _updateWidgetOnly({List? todayMeals}) async { + final schoolId = _currentSchoolId; if (_isWidgetUpdateInProgress) { + AnalyticsService.instance.logWidgetSync( + schoolId: schoolId, + triggerSource: AnalyticsTriggerSource.foreground, + result: WidgetSyncResult.skippedInProgress, + ); if (kDebugMode) { debugPrint('위젯 업데이트 스킵: 이전 작업 진행 중'); } @@ -155,6 +227,11 @@ class _HomeScreenState extends State with WidgetsBindingObserver { if (_lastWidgetUpdateAt != null && nowForDebounce.difference(_lastWidgetUpdateAt!) < _widgetUpdateMinInterval) { + AnalyticsService.instance.logWidgetSync( + schoolId: schoolId, + triggerSource: AnalyticsTriggerSource.foreground, + result: WidgetSyncResult.skippedDebounce, + ); if (kDebugMode) { debugPrint('위젯 업데이트 스킵: 너무 짧은 간격'); } @@ -163,55 +240,26 @@ class _HomeScreenState extends State with WidgetsBindingObserver { _lastWidgetUpdateAt = nowForDebounce; _isWidgetUpdateInProgress = true; - // 오늘 날짜인 경우에만 위젯 업데이트 try { - // 1. 오늘 날짜의 메뉴 데이터 가져오기 (인자로 들어오면 재사용) - final mealsForWidget = - todayMeals ?? await _repository.getMealsForDate(DateTime.now()); - - // 2. 데이터를 시간대별로 그룹화 - final groupedMeals = groupMeals(mealsForWidget); - - // 3. 오늘 운영하는 모든 식당의 고유한 이름과 정보(Hours)를 추출 - final Map uniqueCafeterias = {}; - - // groupedMeals가 비어있으면 이 반복문은 실행되지 않음 -> 안전함 - groupedMeals.values.expand((list) => list).forEach((mealByCafeteria) { - uniqueCafeterias[mealByCafeteria.cafeteriaName] = mealByCafeteria.hours; - }); - - // 4. 각 식당별로 MealWidgetData 객체를 생성하여 리스트에 담기 - final List allCafeteriasData = []; - for (var entry in uniqueCafeterias.entries) { - final cafeteriaName = entry.key; - final hours = entry.value; - - // 기존 fromGrouped 팩토리 생성자를 완벽하게 재사용 - final widgetData = MealWidgetData.fromGrouped( - date: DateFormat('yyyy-MM-dd').format(DateTime.now()), - cafeteriaName: cafeteriaName, - grouped: groupedMeals.map((k, v) => MapEntry(k, v)), - hours: hours, - ); - allCafeteriasData.add(widgetData); - } - - // [핵심] - // 데이터가 없으면 allCafeteriasData는 빈 리스트 []가 됩니다. - // 이 빈 리스트를 그대로 저장하면, 위젯은 데이터를 찾지 못합니다. - - // 5. 모든 식당 데이터가 담긴 리스트를 새로운 컨테이너 모델로 감싸기 - final widgetDataContainer = AllCafeteriasWidgetData( - cafeterias: allCafeteriasData, + final cafeteriaCount = await _widgetSyncHelper.syncWidgetData( + todayMeals: todayMeals, ); - if (kDebugMode) { - debugPrint('✅ ${allCafeteriasData.length}개 식당 위젯 데이터 업데이트 성공!'); + debugPrint('✅ $cafeteriaCount개 식당 위젯 데이터 업데이트 성공!'); } - // 6. 새로운 서비스 함수를 호출하여 통합된 데이터를 저장 - await WidgetService.saveAllCafeteriasWidgetData(widgetDataContainer); + AnalyticsService.instance.logWidgetSync( + schoolId: schoolId, + cafeteriaCount: cafeteriaCount, + triggerSource: AnalyticsTriggerSource.foreground, + result: WidgetSyncResult.success, + ); } catch (e) { + AnalyticsService.instance.logWidgetSync( + schoolId: schoolId, + triggerSource: AnalyticsTriggerSource.foreground, + result: WidgetSyncResult.failure, + ); // 위젯 업데이트 실패는 조용히 무시 if (kDebugMode) { debugPrint('위젯 업데이트 실패: $e'); @@ -225,6 +273,20 @@ class _HomeScreenState extends State with WidgetsBindingObserver { return a.year == b.year && a.month == b.month && a.day == b.day; } + int? get _currentSchoolId => + context.read().selectedUniversity?.schoolId; + + AnalyticsDataSource _toAnalyticsDataSource(MealDataSource dataSource) { + switch (dataSource) { + case MealDataSource.dbHit: + return AnalyticsDataSource.dbHit; + case MealDataSource.apiFetched: + return AnalyticsDataSource.apiFetched; + case MealDataSource.dbStaleFallback: + return AnalyticsDataSource.dbStaleFallback; + } + } + /// StaleDataException 발생 시 SnackBar를 띄우는 헬퍼 함수 void _showStaleDataSnackbar(StaleDataException e) { // SnackBar는 build가 완료된 후에 띄워야 하므로 addPostFrameCallback 사용 @@ -244,45 +306,128 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }); } - void _loadMeals() { + void _retryMeals() { + _analyticsHelper.setMealRequestContext( + requestType: MealApiRequestType.retry, + ); setState(() { _mealFuture = _fetchData(); }); } void _changeSelectedDateByDays(int days) { + final previousDate = _selectedDate; + final nextDate = _selectedDate.add(Duration(days: days)); + _analyticsHelper.setMealRequestContext( + requestType: MealApiRequestType.dateChange, + changeSource: AnalyticsChangeSource.swipe, + ); + _analyticsHelper.logDateChangeIfNeeded( + schoolId: _currentSchoolId, + previousDate: previousDate, + nextDate: nextDate, + changeSource: AnalyticsChangeSource.swipe, + ); + _analyticsHelper.resetStateExposureGuards(); + setState(() { _dateTransitionDirection = days >= 0 ? 1 : -1; - _selectedDate = _selectedDate.add(Duration(days: days)); + _selectedDate = nextDate; _mealFuture = _fetchData(); }); } /// Pull-to-Refresh(당겨서 새로고침)을 위한 새로고침 함수 Future _refreshMeals() async { + _analyticsHelper.setMealRequestContext( + requestType: MealApiRequestType.userPullToRefresh, + ); + final schoolId = _currentSchoolId; + final mealDate = _analyticsHelper.toDateKey(_selectedDate); + setState(() { // catchError 내부를 async로 만들어 await를 사용할 수 있게 합니다. - _mealFuture = _repository.forceRefreshMeals(_selectedDate).catchError(( - e, - ) async { - // 1. API 호출이 실패하면 (SocketException 등) - if (e is SocketException) { - // 2. 로컬 DB에 저장된 데이터라도 있는지 확인합니다. - final localData = await _repository.fetchFromDb(_selectedDate); - if (localData.isNotEmpty) { - // 3a. 로컬 데이터가 있으면, SnackBar를 띄우고 그 데이터를 반환합니다. - _showStaleDataSnackbar( - StaleDataException( - localData, - message: "새로고침에 실패했습니다. 오프라인 정보를 표시합니다.", - ), - ); - return localData; - } - } - // 3b. 로컬 데이터조차 없거나 다른 종류의 에러이면, 에러 화면을 보여줍니다. - throw NetworkException(); - }); + _mealFuture = _repository + .forceRefreshMeals(_selectedDate) + .then((meals) { + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: MealApiRequestType.userPullToRefresh, + dataSource: AnalyticsDataSource.apiFetched, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.success, + ); + AnalyticsService.instance.logViewMeal( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: _analyticsHelper.dateOffsetFromToday(_selectedDate), + dataSource: AnalyticsDataSource.apiFetched, + mealCount: meals.length, + ); + } + return meals; + }) + .catchError((e) async { + // 1. API 호출이 실패하면 (SocketException 등) + if (e is SocketException) { + // 2. 로컬 DB에 저장된 데이터라도 있는지 확인합니다. + final localData = await _repository.fetchFromDb(_selectedDate); + if (localData.isNotEmpty) { + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: MealApiRequestType.userPullToRefresh, + dataSource: AnalyticsDataSource.dbStaleFallback, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.staleData, + ); + AnalyticsService.instance.logViewMeal( + schoolId: schoolId, + mealDate: mealDate, + dateOffset: _analyticsHelper.dateOffsetFromToday( + _selectedDate, + ), + dataSource: AnalyticsDataSource.dbStaleFallback, + mealCount: localData.length, + ); + } + // 3a. 로컬 데이터가 있으면, SnackBar를 띄우고 그 데이터를 반환합니다. + _showStaleDataSnackbar( + StaleDataException( + localData, + message: "새로고침에 실패했습니다. 오프라인 정보를 표시합니다.", + ), + ); + return localData; + } + + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: MealApiRequestType.userPullToRefresh, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.networkError, + ); + } + // 3b. 로컬 데이터조차 없으면 에러 화면을 보여줍니다. + throw NetworkException(); + } + + if (schoolId != null) { + AnalyticsService.instance.logMealApiRequest( + schoolId: schoolId, + mealDate: mealDate, + requestType: MealApiRequestType.userPullToRefresh, + triggerSource: AnalyticsTriggerSource.foreground, + result: MealApiResult.unknownError, + ); + } + throw NetworkException(); + }); }); } @@ -295,6 +440,19 @@ class _HomeScreenState extends State with WidgetsBindingObserver { lastDate: DateTime(2030), ); if (picked != null && picked != _selectedDate) { + final previousDate = _selectedDate; + _analyticsHelper.setMealRequestContext( + requestType: MealApiRequestType.dateChange, + changeSource: AnalyticsChangeSource.picker, + ); + _analyticsHelper.logDateChangeIfNeeded( + schoolId: _currentSchoolId, + previousDate: previousDate, + nextDate: picked, + changeSource: AnalyticsChangeSource.picker, + ); + _analyticsHelper.resetStateExposureGuards(); + setState(() { _dateTransitionDirection = picked.isAfter(_selectedDate) ? 1 : -1; _selectedDate = picked; @@ -403,9 +561,17 @@ class _HomeScreenState extends State with WidgetsBindingObserver { Text(message), const SizedBox(height: 16), ElevatedButton( - onPressed: () => setState(() { - _loadMeals(); - }), // 재시도 버튼 + onPressed: () { + final schoolId = _currentSchoolId; + if (schoolId != null) { + AnalyticsService.instance.logMealRetryTap( + schoolId: schoolId, + mealDate: _analyticsHelper.toDateKey(_selectedDate), + previousErrorType: _analyticsHelper.errorTypeOf(error), + ); + } + _retryMeals(); + }, // 재시도 버튼 child: const Text("다시 시도"), ), ], @@ -617,16 +783,29 @@ class _HomeScreenState extends State with WidgetsBindingObserver { ) // 에러 발생 else if (snapshot.hasError) - SliverFillRemaining( - hasScrollBody: false, - child: _buildErrorWidget(snapshot.error!), - ) + () { + _analyticsHelper.logErrorStateIfNeeded( + schoolId: _currentSchoolId, + selectedDate: _selectedDate, + error: snapshot.error!, + ); + return SliverFillRemaining( + hasScrollBody: false, + child: _buildErrorWidget(snapshot.error!), + ); + }() // 데이터 없을 시 비어있음 표시 else if (!snapshot.hasData || snapshot.data!.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _buildEmptyState(), - ) + () { + _analyticsHelper.logEmptyStateIfNeeded( + schoolId: _currentSchoolId, + selectedDate: _selectedDate, + ); + return SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyState(), + ); + }() // 데이터 로딩 성공 -> MealList 위젯 생성 else _buildMealList(snapshot.data!), // Sliver 직접 추가 @@ -710,29 +889,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { // iconSize: 33.w, tooltip: '설정', // 풍선 도움말 onPressed: () { - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - const SettingsScreen(), - transitionsBuilder: - (context, animation, secondaryAnimation, child) { - const begin = Offset(1.0, 0.0); // 오른쪽에서 시작 - const end = Offset.zero; // 원래 위치로 이동 - const curve = Curves.ease; // 부드러운 전환 효과 - - var tween = Tween( - begin: begin, - end: end, - ).chain(CurveTween(curve: curve)); - var offsetAnimation = animation.drive(tween); - - return SlideTransition( - position: offsetAnimation, - child: child, - ); - }, - ), - ); + Navigator.of(context).pushNamed('/settings'); }, padding: EdgeInsets.zero, // 내부 패딩 제거 constraints: const BoxConstraints(), // 최소 크기 제한(48px) 제거 diff --git a/lib/screens/home_widget_sync_helper.dart b/lib/screens/home_widget_sync_helper.dart new file mode 100644 index 0000000..c8d2eb5 --- /dev/null +++ b/lib/screens/home_widget_sync_helper.dart @@ -0,0 +1,53 @@ +import 'package:bobmoo/collections/meal_collection.dart'; +import 'package:bobmoo/models/all_cafeterias_widget_data.dart'; +import 'package:bobmoo/models/menu_model.dart'; +import 'package:bobmoo/models/meal_widget_data.dart'; +import 'package:bobmoo/repositories/meal_repository.dart'; +import 'package:bobmoo/services/widget_service.dart'; +import 'package:bobmoo/utils/meal_utils.dart'; +import 'package:intl/intl.dart'; + +class HomeWidgetSyncHelper { + HomeWidgetSyncHelper({required MealRepository repository}) + : _repository = repository; + + final MealRepository _repository; + + Future syncWidgetData({List? todayMeals}) async { + // 1. 오늘 날짜의 메뉴 데이터 가져오기 (인자로 들어오면 재사용) + final mealsForWidget = + todayMeals ?? await _repository.getMealsForDate(DateTime.now()); + + // 2. 데이터를 시간대별로 그룹화 + final groupedMeals = groupMeals(mealsForWidget); + + // 3. 오늘 운영하는 모든 식당의 고유한 이름과 정보(Hours)를 추출 + final uniqueCafeterias = {}; + + // groupedMeals가 비어있으면 이 반복문은 실행되지 않음 -> 안전함 + for (final mealByCafeteria in groupedMeals.values.expand((list) => list)) { + uniqueCafeterias[mealByCafeteria.cafeteriaName] = mealByCafeteria.hours; + } + + // 4. 각 식당별로 MealWidgetData 객체를 생성하여 리스트에 담기 + final allCafeteriasData = []; + for (final entry in uniqueCafeterias.entries) { + allCafeteriasData.add( + MealWidgetData.fromGrouped( + date: DateFormat('yyyy-MM-dd').format(DateTime.now()), + cafeteriaName: entry.key, + grouped: groupedMeals.map((k, v) => MapEntry(k, v)), + hours: entry.value, + ), + ); + } + + // 5. 모든 식당 데이터가 담긴 리스트를 새로운 컨테이너 모델로 감싸기 + final widgetDataContainer = AllCafeteriasWidgetData( + cafeterias: allCafeteriasData, + ); + + await WidgetService.saveAllCafeteriasWidgetData(widgetDataContainer); + return allCafeteriasData.length; + } +} diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart index fc11cac..eed267f 100644 --- a/lib/screens/onboarding_screen.dart +++ b/lib/screens/onboarding_screen.dart @@ -69,7 +69,10 @@ class _OnboardingScreenState extends State { Future _openSelectSchool() async { final University? university = await Navigator.of( context, - ).pushNamed("/select_school", arguments: false); + ).pushNamed( + "/select_school", + arguments: {'allowBack': false, 'entryPoint': 'onboarding'}, + ); if (!mounted) return; diff --git a/lib/screens/select_school_screen.dart b/lib/screens/select_school_screen.dart index 73daa02..df85f74 100644 --- a/lib/screens/select_school_screen.dart +++ b/lib/screens/select_school_screen.dart @@ -1,5 +1,6 @@ import 'package:bobmoo/models/university.dart'; import 'package:bobmoo/providers/search_provider.dart'; +import 'package:bobmoo/services/analytics_service.dart'; import 'package:bobmoo/providers/univ_provider.dart'; import 'package:bobmoo/ui/components/buttons/primary_button.dart'; import 'package:bobmoo/ui/theme/app_colors.dart'; @@ -11,10 +12,12 @@ import 'package:provider/provider.dart'; class SelectSchoolScreen extends StatefulWidget { final bool allowBack; + final String entryPoint; const SelectSchoolScreen({ super.key, required this.allowBack, + required this.entryPoint, }); @override @@ -23,13 +26,24 @@ class SelectSchoolScreen extends StatefulWidget { class _SelectSchoolScreenState extends State { University? _selectedUniv; + University? _initialSelectedUniv; + + AnalyticsEntryPoint get _entryPoint { + switch (widget.entryPoint) { + case 'settings': + return AnalyticsEntryPoint.settings; + case 'onboarding': + default: + return AnalyticsEntryPoint.onboarding; + } + } PreferredSizeWidget _buildAppBar() { return AppBar( // Appbar의 기본 여백 제거 titleSpacing: 0, backgroundColor: AppColors.colorGray4, - // 온보딩화면 -> false, 설정화면 -> true + // 뒤로가기 허용 여부는 라우트 인자로 제어 automaticallyImplyLeading: widget.allowBack, scrolledUnderElevation: 0, title: Padding( @@ -47,6 +61,7 @@ class _SelectSchoolScreenState extends State { void initState() { super.initState(); _selectedUniv = context.read().selectedUniversity; + _initialSelectedUniv = _selectedUniv; } @override @@ -72,7 +87,25 @@ class _SelectSchoolScreenState extends State { PrimaryButton( text: "선택완료", onTap: () { - Navigator.of(context).pop(_selectedUniv); + final selectedUniv = _selectedUniv; + if (selectedUniv == null) return; + + final previousUniv = _initialSelectedUniv; + if (previousUniv == null) { + AnalyticsService.instance.logSelectSchool( + schoolId: selectedUniv.schoolId, + entryPoint: _entryPoint, + isFirstSelect: true, + ); + } else if (previousUniv.schoolId != selectedUniv.schoolId) { + AnalyticsService.instance.logChangeSchool( + previousSchoolId: previousUniv.schoolId, + newSchoolId: selectedUniv.schoolId, + entryPoint: _entryPoint, + ); + } + + Navigator.of(context).pop(selectedUniv); }, ), SizedBox(height: 27.h), @@ -125,10 +158,19 @@ class _SelectSchoolScreenState extends State { style: AppTypography.search.b17, ), onTap: () { + final nextUniv = _selectedUniv == university + ? null + : university; + if (nextUniv != null) { + AnalyticsService.instance.logSchoolSearchResultTap( + schoolId: nextUniv.schoolId, + resultRank: index + 1, + entryPoint: _entryPoint, + ); + } + setState(() { - _selectedUniv = _selectedUniv == university - ? null - : university; + _selectedUniv = nextUniv; }); }, ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c91d257..8eb8196 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -117,7 +117,10 @@ class _SettingsScreenState extends State Future _openSelectSchool() async { final University? university = await Navigator.of( context, - ).pushNamed("/select_school", arguments: false); + ).pushNamed( + "/select_school", + arguments: {'allowBack': false, 'entryPoint': 'settings'}, + ); if (!mounted) return; diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart new file mode 100644 index 0000000..94ddb7a --- /dev/null +++ b/lib/services/analytics_service.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:flutter/foundation.dart'; + +enum AppGateDestinationRoute { + home('/home'), + onboarding('/onboarding'); + + const AppGateDestinationRoute(this.value); + final String value; +} + +enum AnalyticsEntryPoint { + onboarding('onboarding'), + settings('settings'); + + const AnalyticsEntryPoint(this.value); + final String value; +} + +enum SchoolListLoadResult { + success('success'), + failure('failure'); + + const SchoolListLoadResult(this.value); + final String value; +} + +enum AnalyticsChangeSource { + swipe('swipe'), + picker('picker'); + + const AnalyticsChangeSource(this.value); + final String value; +} + +enum MealApiRequestType { + initialLoad('initial_load'), + retry('retry'), + userPullToRefresh('user_pull_to_refresh'), + dateChange('date_change'); + + const MealApiRequestType(this.value); + final String value; +} + +enum MealApiResult { + success('success'), + networkError('network_error'), + staleData('stale_data'), + unknownError('unknown_error'); + + const MealApiResult(this.value); + final String value; +} + +enum AnalyticsDataSource { + dbHit('db_hit'), + apiFetched('api_fetched'), + dbStaleFallback('db_stale_fallback'); + + const AnalyticsDataSource(this.value); + final String value; +} + +enum AnalyticsTriggerSource { + foreground('foreground'), + backgroundWorkmanager('background_workmanager'); + + const AnalyticsTriggerSource(this.value); + final String value; +} + +enum AnalyticsErrorType { + networkError('network_error'), + unknownError('unknown_error'); + + const AnalyticsErrorType(this.value); + final String value; +} + +enum WidgetSyncResult { + success('success'), + failure('failure'), + skippedInProgress('skipped_in_progress'), + skippedDebounce('skipped_debounce'); + + const WidgetSyncResult(this.value); + final String value; +} + +class AnalyticsService { + AnalyticsService._(); + + static final AnalyticsService instance = AnalyticsService._(); + final FirebaseAnalytics _analytics = FirebaseAnalytics.instance; + bool _isInitialized = false; + + String get environment => kReleaseMode ? 'prod' : 'dev'; + + Future initialize() async { + if (_isInitialized) return; + _isInitialized = true; + + try { + await _analytics.setDefaultEventParameters({ + 'env': environment, + }); + await _analytics.setUserProperty( + name: 'env', + value: environment, + ); + } catch (error) { + if (kDebugMode) { + debugPrint('[Analytics] initialize failed: $error'); + } + } + } + + void logAppGateDecision({ + required AppGateDestinationRoute destinationRoute, + required bool hasSelectedSchool, + }) { + _logEvent( + name: 'app_gate_decision', + parameters: { + 'destination_route': destinationRoute.value, + 'has_selected_school': hasSelectedSchool, + }, + ); + } + + void logSchoolListLoadResult({ + required SchoolListLoadResult result, + int? schoolCount, + int? loadTimeMs, + }) { + _logEvent( + name: 'school_list_load_result', + parameters: { + 'result': result.value, + 'school_count': schoolCount, + 'load_time_ms': loadTimeMs, + 'screen_name': 'select_school_screen', + }, + ); + } + + void logSchoolSearchResultTap({ + required int schoolId, + required int resultRank, + required AnalyticsEntryPoint entryPoint, + }) { + _logEvent( + name: 'school_search_result_tap', + parameters: { + 'school_id': schoolId, + 'result_rank': resultRank, + 'entry_point': entryPoint.value, + 'screen_name': 'select_school_screen', + }, + ); + } + + void logSelectSchool({ + required int schoolId, + required AnalyticsEntryPoint entryPoint, + required bool isFirstSelect, + }) { + _logEvent( + name: 'select_school', + parameters: { + 'school_id': schoolId, + 'entry_point': entryPoint.value, + 'is_first_select': isFirstSelect, + }, + ); + } + + void logChangeSchool({ + required int previousSchoolId, + required int newSchoolId, + required AnalyticsEntryPoint entryPoint, + }) { + _logEvent( + name: 'change_school', + parameters: { + 'previous_school_id': previousSchoolId, + 'new_school_id': newSchoolId, + 'entry_point': entryPoint.value, + }, + ); + } + + void logDateChange({ + required int schoolId, + required String previousDate, + required String mealDate, + required int dateOffset, + required AnalyticsChangeSource changeSource, + required int daysDelta, + }) { + _logEvent( + name: 'date_change', + parameters: { + 'school_id': schoolId, + 'previous_date': previousDate, + 'meal_date': mealDate, + 'date_offset': dateOffset, + 'change_source': changeSource.value, + 'days_delta': daysDelta, + }, + ); + } + + void logMealApiRequest({ + required int schoolId, + required String mealDate, + required MealApiRequestType requestType, + AnalyticsChangeSource? changeSource, + AnalyticsDataSource? dataSource, + required AnalyticsTriggerSource triggerSource, + required MealApiResult result, + }) { + _logEvent( + name: 'meal_api_request', + parameters: { + 'school_id': schoolId, + 'meal_date': mealDate, + 'request_type': requestType.value, + 'change_source': changeSource?.value, + 'data_source': dataSource?.value, + 'trigger_source': triggerSource.value, + 'result': result.value, + }, + ); + } + + void logViewMeal({ + required int schoolId, + required String mealDate, + required int dateOffset, + AnalyticsDataSource? dataSource, + int? mealCount, + }) { + _logEvent( + name: 'view_meal', + parameters: { + 'school_id': schoolId, + 'meal_date': mealDate, + 'date_offset': dateOffset, + 'data_source': dataSource?.value, + 'meal_count': mealCount, + }, + ); + } + + void logMealEmptyStateView({ + required int schoolId, + required String mealDate, + required int dateOffset, + }) { + _logEvent( + name: 'meal_empty_state_view', + parameters: { + 'school_id': schoolId, + 'meal_date': mealDate, + 'date_offset': dateOffset, + 'screen_name': 'home_screen', + }, + ); + } + + void logMealErrorStateView({ + required int schoolId, + required String mealDate, + required int dateOffset, + required AnalyticsErrorType errorType, + }) { + _logEvent( + name: 'meal_error_state_view', + parameters: { + 'school_id': schoolId, + 'meal_date': mealDate, + 'date_offset': dateOffset, + 'error_type': errorType.value, + }, + ); + } + + void logMealRetryTap({ + required int schoolId, + required String mealDate, + required AnalyticsErrorType previousErrorType, + }) { + _logEvent( + name: 'meal_retry_tap', + parameters: { + 'school_id': schoolId, + 'meal_date': mealDate, + 'previous_error_type': previousErrorType.value, + 'screen_name': 'home_screen', + }, + ); + } + + void logWidgetSync({ + int? schoolId, + int? cafeteriaCount, + required AnalyticsTriggerSource triggerSource, + required WidgetSyncResult result, + }) { + _logEvent( + name: 'widget_sync', + parameters: { + 'school_id': schoolId, + 'cafeteria_count': cafeteriaCount, + 'trigger_source': triggerSource.value, + 'result': result.value, + }, + ); + } + + void _logEvent({ + required String name, + required Map parameters, + }) { + final normalized = {}; + + parameters.forEach((key, value) { + if (value == null) return; + + if (value is String || value is int || value is double || value is bool) { + normalized[key] = value; + return; + } + + normalized[key] = value.toString(); + }); + + unawaited(_safeLogEvent(name: name, parameters: normalized)); + } + + Future _safeLogEvent({ + required String name, + required Map parameters, + }) async { + try { + await _analytics.logEvent(name: name, parameters: parameters); + } catch (error) { + if (kDebugMode) { + debugPrint('[Analytics] logEvent failed: $name, error: $error'); + } + } + } +} diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index f39bb57..29f903c 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -1,13 +1,10 @@ import 'package:bobmoo/constants/app_constants.dart'; import 'package:bobmoo/locator.dart'; -import 'package:bobmoo/models/all_cafeterias_widget_data.dart'; -import 'package:bobmoo/models/meal_widget_data.dart'; -import 'package:bobmoo/models/menu_model.dart'; import 'package:bobmoo/repositories/meal_repository.dart'; -import 'package:bobmoo/services/widget_service.dart'; -import 'package:bobmoo/utils/meal_utils.dart'; +import 'package:bobmoo/screens/home_widget_sync_helper.dart'; +import 'package:bobmoo/services/analytics_service.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; import 'package:workmanager/workmanager.dart'; // WorkManager가 호출할 최상위 함수. @pragma 어노테이션은 Dart 컴파일러에게 이 함수가 코드상에서 @@ -15,6 +12,9 @@ import 'package:workmanager/workmanager.dart'; @pragma('vm:entry-point') void callbackDispatcher() { Workmanager().executeTask((task, inputData) async { + await Firebase.initializeApp(); + await AnalyticsService.instance.initialize(); + // Locator (GetIt)를 초기화합니다. 백그라운드 isolate는 앱의 메인 isolate와 // 메모리를 공유하지 않으므로, 사용하는 서비스들을 다시 초기화해야 합니다. await setupLocator(); @@ -25,10 +25,16 @@ void callbackDispatcher() { try { // home_screen.dart에 있던 위젯 업데이트 로직을 그대로 사용합니다. final repository = locator(); + final syncHelper = HomeWidgetSyncHelper(repository: repository); final today = DateTime.now(); final todayMeals = await repository.getMealsForDate(today); if (todayMeals.isEmpty) { + AnalyticsService.instance.logWidgetSync( + cafeteriaCount: 0, + triggerSource: AnalyticsTriggerSource.backgroundWorkmanager, + result: WidgetSyncResult.success, + ); if (kDebugMode) { debugPrint( '[BackgroundService] No meals for today. Skipping widget update.', @@ -38,39 +44,24 @@ void callbackDispatcher() { return Future.value(true); // 데이터가 없으면 성공으로 처리 } - // 데이터 가공 로직 - final groupedMeals = groupMeals(todayMeals); - final Map uniqueCafeterias = {}; - groupedMeals.values.expand((list) => list).forEach((mealByCafeteria) { - uniqueCafeterias[mealByCafeteria.cafeteriaName] = - mealByCafeteria.hours; - }); - - final List allCafeteriasData = []; - for (var entry in uniqueCafeterias.entries) { - final cafeteriaName = entry.key; - final hours = entry.value; - final widgetData = MealWidgetData.fromGrouped( - date: DateFormat('yyyy-MM-dd').format(today), - cafeteriaName: cafeteriaName, - grouped: groupedMeals.map((k, v) => MapEntry(k, v)), - hours: hours, - ); - allCafeteriasData.add(widgetData); - } - - final widgetDataContainer = AllCafeteriasWidgetData( - cafeterias: allCafeteriasData, + final cafeteriaCount = await syncHelper.syncWidgetData( + todayMeals: todayMeals, + ); + AnalyticsService.instance.logWidgetSync( + cafeteriaCount: cafeteriaCount, + triggerSource: AnalyticsTriggerSource.backgroundWorkmanager, + result: WidgetSyncResult.success, ); - - // 위젯 데이터 저장 및 업데이트 - await WidgetService.saveAllCafeteriasWidgetData(widgetDataContainer); if (kDebugMode) { debugPrint('[BackgroundService] Successfully updated widget data.'); } return Future.value(true); // 성공 } catch (e) { + AnalyticsService.instance.logWidgetSync( + triggerSource: AnalyticsTriggerSource.backgroundWorkmanager, + result: WidgetSyncResult.failure, + ); if (kDebugMode) { debugPrint('[BackgroundService] Error executing task: $e'); }