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 권한 온보딩/설정 문구 현지화 키 추가
This commit is contained in:
JiWoong Sul
2025-09-07 21:32:16 +09:00
parent d1a6cb9fe3
commit d37f66d526
53 changed files with 435 additions and 290 deletions

View File

@@ -3,7 +3,6 @@ import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
@@ -47,11 +46,11 @@ class AddSubscriptionEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
offset: Offset(0, 4),
),
],
),
@@ -147,7 +146,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
),
child: Row(
children: [
Icon(
const Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
@@ -175,7 +174,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
}
return Text(
infoText,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,

View File

@@ -5,7 +5,6 @@ import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../providers/locale_provider.dart';
import '../../theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget {
@@ -33,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
color: borderColor,
width: 2,
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,

View File

@@ -6,6 +6,7 @@ import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
import '../utils/logger.dart';
import 'animated_page_transitions.dart';
import '../l10n/app_localizations.dart';
@@ -44,7 +45,7 @@ class AppNavigator {
/// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(
BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
Log.d('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact();
try {
@@ -52,9 +53,9 @@ class AppNavigator {
AppRoutes.subscriptionDetail,
arguments: subscription,
);
print('DetailScreen 네비게이션 성공');
Log.d('DetailScreen 네비게이션 성공');
} catch (e) {
print('DetailScreen 네비게이션 오류: $e');
Log.e('DetailScreen 네비게이션 오류', e);
}
}

View File

@@ -42,7 +42,6 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;

View File

@@ -81,8 +81,8 @@ class LoadingDialog {
context: context,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor ?? Colors.black54,
builder: (context) => WillPopScope(
onWillPop: () async => barrierDismissible,
builder: (context) => PopScope(
canPop: barrierDismissible,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),

View File

@@ -66,7 +66,7 @@ class BaseTextField extends StatelessWidget {
if (label != null) ...[
Text(
label!,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
@@ -91,13 +91,13 @@ class BaseTextField extends StatelessWidget {
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
style: style ??
TextStyle(
const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
hintStyle: const TextStyle(
color: AppColors.textMuted,
),
prefixIcon: prefixIcon,

View File

@@ -48,7 +48,7 @@ class DatePickerField extends StatelessWidget {
children: [
Text(
label,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
@@ -249,7 +249,7 @@ class _DateRangeItem extends StatelessWidget {
children: [
Text(
label,
style: TextStyle(
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),

View File

@@ -200,7 +200,7 @@ class AppSnackBar {
width: 24,
height: 24,
margin: const EdgeInsets.only(right: 12),
child: CircularProgressIndicator(
child: const CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.pureWhite,
),

View File

@@ -44,11 +44,11 @@ class DetailEventSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
offset: Offset(0, 4),
),
],
),
@@ -118,7 +118,7 @@ class DetailEventSection extends StatelessWidget {
),
child: Row(
children: [
Icon(
const Icon(
Icons.info_outline_rounded,
color: AppColors.infoColor,
size: 20,
@@ -127,7 +127,7 @@ class DetailEventSection extends StatelessWidget {
Expanded(
child: Text(
AppLocalizations.of(context).eventPriceHint,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
@@ -253,8 +253,8 @@ class _DiscountBadge extends StatelessWidget {
const SizedBox(width: 12),
Text(
_getLocalizedDiscountAmount(context, currency, discountAmount),
style: TextStyle(
color: const Color(0xFF15803D),
style: const TextStyle(
color: Color(0xFF15803D),
fontSize: 14,
fontWeight: FontWeight.w500,
),

View File

@@ -49,11 +49,11 @@ class DetailFormSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
offset: Offset(0, 4),
),
],
),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/locale_provider.dart';

View File

@@ -41,11 +41,11 @@ class DetailUrlSection extends StatelessWidget {
color: AppColors.glassBorder.withValues(alpha: 0.1),
width: 1,
),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
offset: Offset(0, 4),
),
],
),
@@ -89,7 +89,7 @@ class DetailUrlSection extends StatelessWidget {
label: AppLocalizations.of(context).websiteUrl,
hintText: AppLocalizations.of(context).urlExample,
keyboardType: TextInputType.url,
prefixIcon: Icon(
prefixIcon: const Icon(
Icons.link_rounded,
color: AppColors.navyGray,
),
@@ -114,7 +114,7 @@ class DetailUrlSection extends StatelessWidget {
children: [
Row(
children: [
Icon(
const Icon(
Icons.info_outline_rounded,
color: AppColors.warningColor,
size: 20,
@@ -122,7 +122,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).cancelGuide,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
@@ -133,7 +133,7 @@ class DetailUrlSection extends StatelessWidget {
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).cancelServiceGuide,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
@@ -167,7 +167,7 @@ class DetailUrlSection extends StatelessWidget {
),
child: Row(
children: [
Icon(
const Icon(
Icons.auto_fix_high_rounded,
color: AppColors.infoColor,
size: 20,
@@ -176,7 +176,7 @@ class DetailUrlSection extends StatelessWidget {
Expanded(
child: Text(
AppLocalizations.of(context).urlAutoMatchInfo,
style: TextStyle(
style: const TextStyle(
fontSize: 14,
color: AppColors.darkNavy,
fontWeight: FontWeight.w500,

View File

@@ -111,7 +111,7 @@ class EmptyStateWidget extends StatelessWidget {
},
child: Text(
AppLocalizations.of(context).addSubscription,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,

View File

@@ -163,7 +163,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors
.map((color) => color.withOpacity(0.3))
.map((color) => color.withValues(alpha: 0.3))
.toList(),
),
),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../utils/logger.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import 'themed_text.dart';
@@ -74,12 +75,12 @@ class GlassmorphismCard extends StatelessWidget {
),
boxShadow: boxShadow ??
[
BoxShadow(
const BoxShadow(
color: AppColors
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
offset: Offset(0, 10),
),
],
),
@@ -200,7 +201,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
_handleTapUp(details);
// onTap 콜백 실행
if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
Log.d('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!();
}
},

View File

@@ -90,7 +90,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
AppLocalizations.of(context)
.monthlyTotalSubscriptionCost,
style: TextStyle(
style: const TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15,
@@ -215,7 +215,7 @@ class MainScreenSummaryCard extends StatelessWidget {
context,
title: AppLocalizations.of(context)
.estimatedAnnualCost,
value: '${NumberFormat.currency(
value: NumberFormat.currency(
locale: defaultCurrency == 'KRW'
? 'ko_KR'
: defaultCurrency == 'JPY'
@@ -225,7 +225,7 @@ class MainScreenSummaryCard extends StatelessWidget {
: 'en_US',
symbol: currencySymbol,
decimalDigits: decimals,
).format(yearlyCost)}',
).format(yearlyCost),
),
const SizedBox(width: 16),
_buildInfoBox(
@@ -282,7 +282,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
AppLocalizations.of(context)
.eventDiscountActive,
style: TextStyle(
style: const TextStyle(
color: AppColors
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11,
@@ -373,7 +373,7 @@ class MainScreenSummaryCard extends StatelessWidget {
children: [
Text(
title,
style: TextStyle(
style: const TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,

View File

@@ -14,7 +14,7 @@ class ScanLoadingWidget extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
),
const SizedBox(height: 16),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
@@ -202,8 +201,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
daysUntilNext = 7; // 다음 주 같은 요일
}
if (daysUntilNext == 0)
if (daysUntilNext == 0) {
return AppLocalizations.of(context).paymentDueToday;
}
return AppLocalizations.of(context).paymentDueInDays(daysUntilNext);
}
@@ -303,8 +303,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
width: double.infinity, // 전체 너비를 차지하도록 설정
onTap: widget.onTap ??
() async {
print(
'[SubscriptionCard] AnimatedGlassmorphismCard onTap 호출됨 - ${widget.subscription.serviceName}');
// ignore: use_build_context_synchronously
await AppNavigator.toDetail(context, widget.subscription);
},
child: Column(

View File

@@ -12,6 +12,7 @@ import '../services/subscription_url_matcher.dart';
import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
@@ -100,7 +101,7 @@ class SubscriptionListWidget extends StatelessWidget {
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
onTap: () {
print(
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
@@ -122,13 +123,15 @@ class SubscriptionListWidget extends StatelessWidget {
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete && context.mounted) {
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart';
@@ -29,7 +28,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
static const double _tapTolerance = 20.0; // 탭 허용 범위
static const double _actionThresholdPercent = 0.15;
static const double _deleteThresholdPercent = 0.40;
static const int _tapDurationMs = 500;
static const double _velocityThreshold = 800.0;
// static const double _animationDuration = 300.0;
@@ -39,8 +37,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 추적
Offset? _startPosition;
DateTime? _startTime;
bool _isValidTap = true;
// 제스처 관련 보조 변수(간소화)
// 상태 관리
double _currentOffset = 0;
@@ -95,8 +92,6 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
// 제스처 핸들러
void _handlePanStart(DragStartDetails details) {
_startPosition = details.localPosition;
_startTime = DateTime.now();
_isValidTap = true;
_hapticTriggered = false;
_controller.stop();
}
@@ -104,12 +99,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
void _handlePanUpdate(DragUpdateDetails details) {
final currentPosition = details.localPosition;
final delta = currentPosition.dx - _startPosition!.dx;
final distance = (currentPosition - _startPosition!).distance;
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
if (distance > _tapTolerance) {
_isValidTap = false;
}
// 탭/스와이프 판별 거리는 외부에서 사용하지 않아 제거
// 카드 이동
setState(() {
@@ -129,14 +119,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
}
// 헬퍼 메서드
void _processTap() {
print('[SwipeableSubscriptionCard] _processTap 호출됨');
if (widget.onTap != null) {
print('[SwipeableSubscriptionCard] onTap 콜백 실행');
widget.onTap!();
}
_animateToOffset(0);
}
// 탭 처리는 SubscriptionCard에서 수행
void _processSwipe(double velocity) {
final extent = _currentOffset.abs();