Files
submanager/lib/screens/analysis_screen.dart
JiWoong Sul d37f66d526 feat(settings): SMS 읽기 권한 상태/요청 위젯 추가 (Android)
- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지

feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android)

- 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동

chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가

- AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가

refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입

- SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환

feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화

test: URL 매처/환율 스모크 테스트 추가

chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지)

fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강

fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체

i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
2025-09-07 21:32:16 +09:00

204 lines
6.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/locale_provider.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart';
import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart';
class AnalysisScreen extends StatefulWidget {
const AnalysisScreen({super.key});
@override
State<AnalysisScreen> createState() => _AnalysisScreenState();
}
class _AnalysisScreenState extends State<AnalysisScreen>
with TickerProviderStateMixin {
late AnimationController _animationController;
late ScrollController _scrollController;
double _totalExpense = 0;
List<Map<String, dynamic>> _monthlyData = [];
bool _isLoading = true;
String _lastDataHash = '';
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_scrollController = ScrollController();
_loadData();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Provider 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final currentHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] didChangeDependencies: '
'현재 해시=$currentHash, 이전 해시=$_lastDataHash, 로딩중=$_isLoading');
// 데이터가 변경되었고 현재 로딩 중이 아닌 경우에만 리로드
if (currentHash != _lastDataHash &&
!_isLoading &&
_lastDataHash.isNotEmpty) {
debugPrint('[AnalysisScreen] 데이터 변경 감지됨, 리로드 시작');
_loadData();
}
}
@override
void dispose() {
_animationController.dispose();
_scrollController.dispose();
super.dispose();
}
/// 구독 데이터의 해시값을 계산하여 변경 감지
String _calculateDataHash(SubscriptionProvider provider) {
final subscriptions = provider.subscriptions;
final buffer = StringBuffer();
buffer.write(subscriptions.length);
buffer.write('_');
buffer.write(provider.totalMonthlyExpense.toStringAsFixed(2));
for (final sub in subscriptions) {
buffer.write(
'_${sub.id}_${sub.currentPrice.toStringAsFixed(2)}_${sub.currency}');
}
return buffer.toString();
}
Future<void> _loadData() async {
debugPrint('[AnalysisScreen] _loadData 호출됨');
setState(() {
_isLoading = true;
});
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
final locale = localeProvider.locale.languageCode;
// 총 지출 계산 (로케일별 기본 통화로 환산)
_totalExpense = await provider.calculateTotalExpense(locale: locale);
debugPrint('[AnalysisScreen] 총 지출 계산 완료: $_totalExpense');
// 월별 데이터 계산 (로케일별 기본 통화로 환산)
_monthlyData = await provider.getMonthlyExpenseData(locale: locale);
debugPrint('[AnalysisScreen] 월별 데이터 계산 완료: ${_monthlyData.length}개월');
// 현재 데이터 해시값 저장
_lastDataHash = _calculateDataHash(provider);
debugPrint('[AnalysisScreen] 데이터 해시값 저장: $_lastDataHash');
setState(() {
_isLoading = false;
});
// 데이터 로드 완료 후 애니메이션 시작
_animationController.reset();
_animationController.forward();
}
Widget _buildAnimatedAd() {
return FadeTransition(
opacity: CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
)),
child: const NativeAdWidget(key: ValueKey('analysis_ad')),
),
);
}
@override
Widget build(BuildContext context) {
// Provider를 직접 사용하여 변경 감지
final provider = Provider.of<SubscriptionProvider>(context);
final subscriptions = provider.subscriptions;
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트
SubscriptionPieChartCard(
subscriptions: subscriptions,
animationController: _animationController,
),
const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'),
subscriptions: subscriptions,
totalExpense: _totalExpense,
animationController: _animationController,
),
const AnalysisScreenSpacer(),
// 3. 월별 지출 차트
MonthlyExpenseChartCard(
key: ValueKey('monthly_expense_$_lastDataHash'),
monthlyData: _monthlyData,
animationController: _animationController,
),
const AnalysisScreenSpacer(),
// 4. 이벤트 분석
EventAnalysisCard(
animationController: _animationController,
),
// FloatingNavigationBar를 위한 충분한 하단 여백
SliverToBoxAdapter(
child: SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
),
],
);
}
}