Compare commits
5 Commits
codex/perf
...
d111b5dd62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d111b5dd62 | ||
|
|
b944f6967d | ||
|
|
997c2f53a0 | ||
|
|
79f9aa3eb0 | ||
|
|
5b72fa196c |
BIN
assets/app_icon/house_check/1024.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/app_icon/house_check/128.png
Normal file
|
After Width: | Height: | Size: 985 B |
BIN
assets/app_icon/house_check/192.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/app_icon/house_check/256.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/app_icon/house_check/32.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
assets/app_icon/house_check/48.png
Normal file
|
After Width: | Height: | Size: 439 B |
BIN
assets/app_icon/house_check/512.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/app_icon/house_check/64.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
assets/app_icon/house_check/96.png
Normal file
|
After Width: | Height: | Size: 776 B |
123
doc/ads.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# AdMob 미디에이션 네이티브 광고 네트워크 (Android)
|
||||
|
||||
아래 네트워크들은 AdMob 미디에이션을 통해 Android에서 네이티브(Native) 광고를 지원합니다. 실제 지원 범위(포맷/통합 방식)는 지역/계정/버전 등에 따라 달라질 수 있으므로 AdMob 콘솔에서 해당 미디에이션 그룹의 포맷 선택 가능 여부로 최종 확인하세요.
|
||||
|
||||
## 권장 후보
|
||||
- Meta Audience Network (FAN)
|
||||
- 통합: Bidding 전용
|
||||
- 포맷: Native, Native Banner
|
||||
- 문서: https://developers.google.com/admob/android/mediation/meta
|
||||
- InMobi
|
||||
- 통합: Waterfall(네이티브는 Waterfall만), Bidding(다른 포맷)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/inmobi
|
||||
- Pangle (ByteDance/TikTok)
|
||||
- 통합: Bidding + Waterfall
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/pangle
|
||||
- Mintegral
|
||||
- 통합: Bidding + Waterfall
|
||||
- 포맷: Native
|
||||
- 메모: 네이티브는 “Native (Custom Rendering)” 선택 지침이 있음
|
||||
- 문서: https://developers.google.com/admob/android/mediation/mintegral
|
||||
- DT Exchange (Fyber)
|
||||
- 통합: Waterfall, Bidding(클로즈드 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/dt-exchange
|
||||
- Moloco
|
||||
- 통합: Bidding
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/moloco
|
||||
- ironSource Ads
|
||||
- 통합: Waterfall(네이티브는 Waterfall만)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/ironsource
|
||||
- Unity Ads
|
||||
- 통합: Waterfall, Bidding(오픈 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/unity
|
||||
- LINE Ads Platform (일본 중심)
|
||||
- 통합: Bidding(네이티브는 클로즈드 베타)
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/line
|
||||
- myTarget (RU/CIS 중심)
|
||||
- 통합: Waterfall
|
||||
- 포맷: Native
|
||||
- 문서: https://developers.google.com/admob/android/mediation/mytarget
|
||||
|
||||
## 참고 및 주의사항
|
||||
- 지역성/수요: Pangle(아시아), LINE(일본), myTarget(RU/CIS) 등은 지역별 수요 차이가 큼. 타겟 지역 기준으로 우선순위 구성 권장.
|
||||
- 통합 방식: 일부는 네이티브가 Waterfall만 지원(InMobi, ironSource), 일부는 Bidding만(Meta), 혼합 지원(Pangle, Mintegral, Unity). 비딩/워터폴 여부에 따라 콘솔 설정이 상이함.
|
||||
- SDK/어댑터: Android Gradle에 각 네트워크 SDK/어댑터 추가가 필요하며, AdMob UI에서 해당 네트워크를 미디에이션 그룹의 “Native” 포맷으로 매핑해야 함. 개인정보/동의 메시징(US State Privacy, GDPR 등)도 파트너 추가 필요.
|
||||
- 템플릿/표시: 대부분 Unified Native 기반 에셋을 제공하나 네트워크별 에셋 세트가 달라 `NativeTemplateStyle` 기반 템플릿 레이아웃 조정이 필요할 수 있음.
|
||||
- AppLovin 유의: 문서상 포맷 표에 Native가 보이더라도 어댑터 변경 이력에 “Native 지원 제거”가 기록되어 있습니다. 실제 지원은 AdMob 콘솔(미디에이션 그룹)에서 포맷 선택 가능 여부로 재확인하세요. 문서: https://developers.google.com/admob/android/mediation/applovin
|
||||
- Flutter 연동: `google_mobile_ads`의 `NativeAd` 로드/리스너/`AdWidget` 사용 패턴은 동일. 네트워크 추가는 네이티브(Android) 쪽 SDK/어댑터 및 콘솔 설정이 핵심.
|
||||
|
||||
## 빠른 적용 체크리스트
|
||||
- [ ] 타겟 지역에 맞는 네트워크 선정(2~5개)
|
||||
- [ ] Android 의존성 추가(네트워크 SDK/어댑터)
|
||||
- [ ] AdMob 콘솔: 미디에이션 그룹 생성(포맷=Native), 각 네트워크 매핑
|
||||
- [ ] 테스트 모드/테스트 광고 확인(네트워크별 테스트 설정 있음)
|
||||
- [ ] 앱 내 네이티브 광고 UI 검수(템플릿/에셋 배치, 정책 준수)
|
||||
|
||||
---
|
||||
|
||||
## 지역별 우선순위 제안(예시)
|
||||
아래는 일반적인 트래픽·수요 기준의 스타트 세트 예시입니다. 실제 퍼포먼스는 앱 카테고리/유저 페르소나/국가별 규제에 따라 달라질 수 있으므로 A/B로 조합을 검증하세요.
|
||||
|
||||
- 한국/일본(KR/JP)
|
||||
- 1군: Meta(FAN, Bidding) + Pangle(Bidding/Waterfall) + LINE(JP, Bidding/Closed Beta for Native)
|
||||
- 보강: Mintegral, InMobi, Unity
|
||||
- 북미/유럽(NA/EU)
|
||||
- 1군: Meta(FAN) + InMobi + Unity + Chartboost
|
||||
- 보강: DT Exchange(Fyber), Moloco
|
||||
- 동남아/인도(SEA/IN)
|
||||
- 1군: InMobi + Pangle + Mintegral + Meta(FAN)
|
||||
- 보강: Unity, DT Exchange
|
||||
- CIS/러시아권
|
||||
- 1군: myTarget
|
||||
- 보강: Mintegral, Unity
|
||||
|
||||
참고: Chartboost는 네이티브 포맷 지원. 지역/장르에 따라 성과 편차가 있어 NA/EU 게임 카테고리에서 보강용으로 고려.
|
||||
|
||||
문서:
|
||||
- Chartboost: https://developers.google.com/admob/android/mediation/chartboost
|
||||
|
||||
---
|
||||
|
||||
## Android Gradle 의존성(예시)
|
||||
Flutter에서 `google_mobile_ads`를 사용해도, 미디에이션 파트너의 Android SDK/어댑터는 Gradle에 직접 추가해야 합니다. 아래 스니펫은 예시이며, “정확한 최신 버전”은 각 네트워크 문서의 Adapter 섹션(Changelog/Artifacts)에서 확인 후 고정하세요.
|
||||
|
||||
프로젝트 수준 `settings.gradle`/리포지토리 설정은 기본 `google()`/`mavenCentral()`이면 충분합니다.
|
||||
|
||||
`android/app/build.gradle` (dependencies 블록)
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
// Google Mobile Ads SDK (보통 어댑터가 transitive로 끌어오지만 명시해도 무방)
|
||||
implementation 'com.google.android.gms:play-services-ads:24.6.0' // 최신 권장 버전으로 교체
|
||||
|
||||
// Mediation adapters (예시 버전; 실제 최신 버전으로 교체)
|
||||
implementation 'com.google.ads.mediation:facebook:6.16.0.0' // Meta Audience Network
|
||||
implementation 'com.google.ads.mediation:pangle:5.5.0.4.0' // Pangle
|
||||
implementation 'com.google.ads.mediation:mintegral:16.5.91.1' // Mintegral
|
||||
implementation 'com.google.ads.mediation:inmobi:10.6.3.0' // InMobi
|
||||
implementation 'com.google.ads.mediation:fyber:8.3.8.0' // DT Exchange(Fyber)
|
||||
implementation 'com.google.ads.mediation:moloco:3.8.0.0' // Moloco
|
||||
implementation 'com.google.ads.mediation:ironsource:8.5.0.1' // ironSource
|
||||
implementation 'com.google.ads.mediation:unity:4.16.0.1' // Unity Ads
|
||||
implementation 'com.google.ads.mediation:mytarget:5.20.0.0' // myTarget
|
||||
// implementation 'com.google.ads.mediation:chartboost:<version>' // Chartboost (필요 시)
|
||||
}
|
||||
```
|
||||
|
||||
버전 확인 팁:
|
||||
- 각 네트워크 가이드의 “Supported integrations and ad formats”/“Changelog”에서 최소/최신 어댑터 버전 확인
|
||||
- Maven Central에서 `com.google.ads.mediation:<artifact>` 검색하여 최신 릴리스 확인
|
||||
- AdMob 콘솔에서 해당 네트워크 추가 시 표시되는 가이드/버전 주석 참조
|
||||
|
||||
설정 체크:
|
||||
- ProGuard/R8 규칙이 필요한 네트워크의 경우 가이드에 명시된 keep 규칙 추가
|
||||
- COPPA/유럽·미국 주 개인정보법 관련 consent 전달(UMP SDK 또는 자체 메시징) 및 파트너 동기화
|
||||
- 테스트: 네트워크 콘솔에서 테스트 모드 또는 테스트 디바이스 ID 설정 후 실제 단말에서 `NativeAd` 로드 확인
|
||||
|
||||
113
doc/plan.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# SubManager UI 리디자인/리팩터링 계획 (flutter-shadcn-ui 기반)
|
||||
|
||||
## 개요
|
||||
- 목적: 앱 전반 UI를 `flutter-shadcn-ui`로 표준화하고, 라이트/다크 테마와 의미 있는 컬러 체계를 구축. 사용하지 않는 코드/파일 정리, 복잡한 알고리즘을 동등 효과의 단순한 구현으로 교체.
|
||||
- 원칙: 색채심리학/게슈탈트 심리학/피츠의 법칙/마이크로인터랙션을 반영. 화면 간 일관성 유지. 사이드 이펙트 최소화(동작/데이터 모델 변경 없이 UI 중심).
|
||||
- 범위: `lib/screens`, `lib/widgets`, `lib/theme` 전반. 일부 `services/*` 단순화 대상 포함(동일 기능 유지).
|
||||
|
||||
## 사전 승인 필요(착수 전)
|
||||
1) 의존성 추가: `flutter_shadcn_ui` (pubspec.yaml).
|
||||
2) 테마 구조 재구성: 기존 `app_theme.dart`, `app_colors.dart` → shadcn 토큰/스케일 중심으로 정리.
|
||||
3) 불용 파일 삭제: 기존 커스텀 위젯·스타일(예: 글라스모피즘 계열 등) 제거.
|
||||
4) 점진적 마이그레이션 방식 선택(권장) 또는 일괄 치환(위험도 높음).
|
||||
|
||||
## 접근 전략(옵션)
|
||||
- 옵션 A: 점진적 이행(권장)
|
||||
1단계 토대(테마/토큰/기본 컴포넌트) → 2단계 주요 화면 치환 → 3단계 잔여 위젯/정리. 리스크 낮고 롤백 용이.
|
||||
- 옵션 B: 일괄 치환
|
||||
모든 화면/컴포넌트를 한 번에 교체. 속도는 빠르나 충돌/리스크 큼. 권장하지 않음.
|
||||
|
||||
이 계획서는 옵션 A를 기준으로 작성합니다.
|
||||
|
||||
## 테마·컬러 설계
|
||||
- 토큰: primary, secondary, success, warning, danger, info, background, foreground, muted, accent, border, card, popover, ring, overlay.
|
||||
- 라이트/다크 지원: 동일 의미 색상(semantics)을 양 테마에 매핑. 최소 WCAG 4.5:1 대비.
|
||||
- 색채심리학 반영(과장 금지, 절제된 사용):
|
||||
- info: 블루(신뢰/안정),
|
||||
- success: 그린(완료/안도),
|
||||
- warning: 앰버(주의 환기),
|
||||
- danger: 레드(중단/삭제),
|
||||
- neutral: 슬레이트/징크 계열(콘텐츠 중심).
|
||||
- 게슈탈트: 시각적 그룹화(카드/섹션/간격 체계), 시선 흐름(타이포·계층), 근접성·유사성 활용.
|
||||
- 피츠의 법칙: 주요 액션 버튼 터치 타깃 ≥ 44dp, 간격 여유.
|
||||
- 마이크로인터랙션: 진입/전환 120–200ms, 물리 기반 커브, Reduced Motion 설정 반영(`utils/reduce_motion.dart` 유지/연동).
|
||||
|
||||
구현 포인트(코드 단계에서 적용):
|
||||
- `ShadcnTheme` 확장 혹은 테마 브리지 레이어 생성(예: `lib/theme/shadcn_theme.dart`) 후 기존 `ThemeData`와 연결.
|
||||
- `TextTheme`/`ColorScheme`를 shadcn 토큰으로 역매핑해 타 3rd-party 위젯과도 일관성 유지.
|
||||
|
||||
## 컴포넌트 매핑(현행 → shadcn)
|
||||
- 버튼: `common/buttons/(primary|secondary)_button.dart` → `Button(variant: primary/secondary)`
|
||||
- 카드: 다수의 카드형 위젯 → `Card` + `CardHeader/Content/Footer`
|
||||
- 다이얼로그: `dialogs/*` → `Dialog`/`AlertDialog` + 의미 색상(위험=red)
|
||||
- 스낵바: `app_snackbar.dart` → `Toast` 또는 `Inline Alert`(상황별)
|
||||
- 입력: `base_text_field.dart`, `currency_input_field.dart`, `date_picker_field.dart`, `selector`류 → `Input`, `Select`, `Popover+Calendar`(날짜)
|
||||
- 네비게이션: `floating_navigation_bar.dart` → shadcn 스타일 버튼/탭/세그먼트 조합(기능은 Navigator 유지)
|
||||
- 리스트/아이템: `subscription_*_card(_widget).dart` → `Card`+`List` 조합, 의미 색상 배지 사용
|
||||
- 배지/상태: `analysis_badge.dart` → `Badge`(success/warning/info)
|
||||
|
||||
차트는 기존 라이브러리 유지, `Card`/토큰 색상만 적용.
|
||||
|
||||
## 화면별 리디자인 가이드
|
||||
- 메인(`main_screen.dart`): 상단 요약(카드), 탭형 네비게이션, FAB 대신 우선작업 배치(피츠 법칙 반영).
|
||||
- 구독 추가(`add_subscription_screen.dart`): 단계적 폼(섹션 카드), 필수/보조 액션 분리, 에러/힌트 색상 표준화.
|
||||
- 상세(`detail_screen.dart`): 정보/행동 분리, 위험 액션은 `danger` 톤, url 영역은 `info` 톤.
|
||||
- 분석(`analysis_screen.dart`): KPI 카드 3열(태블릿), 1열(폰), 차트 색상은 의미 기반 팔레트.
|
||||
- 카테고리 관리(`category_management_screen.dart`): 리스트+인라인 편집, 확인/취소 분리, 경고 색상 남용 금지.
|
||||
- 설정(`settings_screen.dart`): 토글/셀리스트 일관, 테마 전환 즉시 반영, 접근성 강조.
|
||||
- SMS 권한(`sms_permission_screen.dart`): 단일 초점 화면, primary 호출-행동 버튼 + 보조 링크.
|
||||
- 스플래시/잠금: 단순한 브랜드/배경, 과도한 애니메이션 제거.
|
||||
|
||||
## 정리/삭제 대상(마이그레이션 완료 후)
|
||||
- 강한 시각효과 위젯: `animated_wave_background.dart`, `glassmorphism_card.dart`, `glassmorphic_scaffold.dart` 등
|
||||
- 중복/대체 가능: 커스텀 버튼/카드/스낵바/다이얼로그 구현체(치환 완료 후)
|
||||
- 사용되지 않는 유틸: 실사용 참조 0인 파일 전부
|
||||
- 임시/백업: 오래된 백업/실험 파일
|
||||
|
||||
삭제는 단계별 PR에서 “치환 완료 확인 → 삭제” 순으로 안전하게 진행.
|
||||
|
||||
## 알고리즘 단순화(동일 효과 유지)
|
||||
- SMS 스캔(`services/sms_scanner.dart`): 필터→파서→정규화 단일 파이프라인(순수 함수)로 재구성, 캐시/메모리 최적화 과잉 제거.
|
||||
- URL 매처(`services/url_matcher/*`): 정규식 테이블 기반 단일 매칭기로 단순화(사전컴파일 RegExp), 서비스 데이터는 레포지토리 1곳에서 주입.
|
||||
- 환율(`exchange_rate_service.dart`): `CacheManager` TTL 캐시 단일 책임, 만료 시 새로고침. 중복 포맷터/파서 제거.
|
||||
- 알림(`notification_service.dart`): 스케줄/권한 체크를 단일 파사드로 노출, 내부 분기 축소.
|
||||
- 성능 유틸(`performance_optimizer.dart`, `memory_manager.dart`): 체감·유지보수 이점 낮은 미세 최적화 제거, 프레임 드랍 유발 가능 애니메이션 단순화.
|
||||
|
||||
모든 변경은 퍼블릭 API/데이터 모델을 유지해 사이드 이펙트 방지.
|
||||
|
||||
## 테스트/검증
|
||||
- 스크립트: `scripts/check.sh` 전 단계 실행(포맷/분석/테스트). 기존 deprecation 경고는 별 PR로 정리.
|
||||
- 위젯/골든 테스트: 핵심 화면(메인/추가/상세/분석/설정) 라이트/다크 2종 캡처 비교.
|
||||
- 유닛 테스트: URL 매처/환율 캐시/SMS 파이프라인.
|
||||
- 접근성: 대비·포커스·터치 타깃 수동 점검 체크리스트.
|
||||
|
||||
## 작업 단위/PR 계획
|
||||
1) 토대 구축: 의존성 추가 + 테마 브리지 + 핵심 컴포넌트(Button/Input/Card/Dialog) 도입.
|
||||
2) 공용 UI 치환: 스낵바/다이얼로그/폼 필드/카드 템플릿 적용.
|
||||
3) 화면별 리디자인: 메인→추가→상세→분석→설정 순.
|
||||
4) 불용 코드 삭제: 치환 완료 파일 제거.
|
||||
5) 알고리즘 단순화: sms/url/환율/알림 순으로 단일화 + 테스트.
|
||||
6) 마감: 디테일 조정/접근성/성능 점검.
|
||||
|
||||
- 브랜치: `codex/feat-shadcn-migration-*` (단계별).
|
||||
- 커밋: Conventional Commits + 한국어 본문.
|
||||
- 롤백: 각 단계는 기능 플래그/치환 전후 비교가 쉬운 최소 단위로 유지.
|
||||
|
||||
## 위험 및 완화
|
||||
- 리소스 색상/테마 충돌 → 토큰 브리지로 양방향 매핑, 미호환 위젯은 유지.
|
||||
- 3rd-party 차트/네이티브 UI → 표면 색/텍스트만 토큰 적용.
|
||||
- 분석 실패(deprecation) → 별 PR로 API 교체(`activeColor` 등), 마이그레이션과 분리 처리.
|
||||
|
||||
## 승인 체크리스트(Yes/No)
|
||||
- [ ] `flutter_shadcn_ui` 의존성 추가 승인이 필요합니다.
|
||||
- [ ] 테마 구조(shadcn 토큰 중심) 재구성 승인.
|
||||
- [ ] 단계별 불용 파일 삭제 승인.
|
||||
- [ ] 점진적 이행(옵션 A)로 진행 승인.
|
||||
|
||||
## 완료 기준(각 단계)
|
||||
- `scripts/check.sh` 무사 통과(분석 경고 해결 내역은 별 PR 또는 병행).
|
||||
- 라이트/다크 스냅샷 비교 이상 없음.
|
||||
- 대상 화면/컴포넌트 치환 100% 및 구식 코드 제거.
|
||||
|
||||
---
|
||||
작성자 메모: 본 계획은 코드 변경 없이 문서만 추가되었습니다. 승인 후 단계별 구현을 진행합니다.
|
||||
@@ -8,6 +8,9 @@ import '../services/sms_service.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/billing_date_util.dart';
|
||||
import '../utils/business_day_util.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
|
||||
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
|
||||
class AddSubscriptionController {
|
||||
@@ -104,6 +107,26 @@ class AddSubscriptionController {
|
||||
scrollOffset = scrollController.offset;
|
||||
});
|
||||
|
||||
// 언어별 기본 통화 설정
|
||||
try {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
switch (lang) {
|
||||
case 'ko':
|
||||
currency = 'KRW';
|
||||
break;
|
||||
case 'ja':
|
||||
currency = 'JPY';
|
||||
break;
|
||||
case 'zh':
|
||||
currency = 'CNY';
|
||||
break;
|
||||
default:
|
||||
currency = 'USD';
|
||||
}
|
||||
} catch (_) {
|
||||
// Localizations가 아직 준비되지 않은 경우 기본값 유지
|
||||
}
|
||||
|
||||
// 애니메이션 시작
|
||||
animationController!.forward();
|
||||
}
|
||||
@@ -284,25 +307,55 @@ class AddSubscriptionController {
|
||||
setState(() => isLoading = true);
|
||||
|
||||
try {
|
||||
final ctx = context;
|
||||
if (!await SMSService.hasSMSPermission()) {
|
||||
final granted = await SMSService.requestSMSPermission();
|
||||
if (!ctx.mounted) return;
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).smsPermissionRequired,
|
||||
);
|
||||
if (ctx.mounted) {
|
||||
// 영구 거부 여부 확인 후 설정 화면 안내
|
||||
final status = await permission.Permission.sms.status;
|
||||
if (!ctx.mounted) return;
|
||||
if (status.isPermanentlyDenied) {
|
||||
await showDialog(
|
||||
context: ctx,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(ctx).smsPermissionRequired),
|
||||
content:
|
||||
Text(AppLocalizations.of(ctx).permanentlyDeniedMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(AppLocalizations.of(ctx).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await permission.openAppSettings();
|
||||
if (ctx.mounted) Navigator.of(ctx).pop();
|
||||
},
|
||||
child: Text(AppLocalizations.of(ctx).openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
AppSnackBar.showError(
|
||||
context: ctx,
|
||||
message: AppLocalizations.of(ctx).smsPermissionRequired,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final subscriptions = await SMSService.scanSubscriptions();
|
||||
if (!ctx.mounted) return;
|
||||
if (subscriptions.isEmpty) {
|
||||
if (context.mounted) {
|
||||
if (ctx.mounted) {
|
||||
AppSnackBar.showWarning(
|
||||
context: context,
|
||||
message: AppLocalizations.of(context).noSubscriptionSmsFound,
|
||||
context: ctx,
|
||||
message: AppLocalizations.of(ctx).noSubscriptionSmsFound,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -434,12 +487,22 @@ class AddSubscriptionController {
|
||||
double.tryParse(eventPriceController.text.replaceAll(',', ''));
|
||||
}
|
||||
|
||||
// 선택일이 오늘(또는 과거)이면 결제 주기에 맞춰 다음 회차로 보정하여 저장 + 영업일 이월
|
||||
final originalDateOnly = DateTime(
|
||||
nextBillingDate!.year,
|
||||
nextBillingDate!.month,
|
||||
nextBillingDate!.day,
|
||||
);
|
||||
var adjustedNext =
|
||||
BillingDateUtil.ensureFutureDate(originalDateOnly, billingCycle);
|
||||
adjustedNext = BusinessDayUtil.nextBusinessDay(adjustedNext);
|
||||
|
||||
await Provider.of<SubscriptionProvider>(context, listen: false)
|
||||
.addSubscription(
|
||||
serviceName: serviceNameController.text.trim(),
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: nextBillingDate!,
|
||||
nextBillingDate: adjustedNext,
|
||||
websiteUrl: websiteUrlController.text.trim(),
|
||||
categoryId: selectedCategoryId,
|
||||
currency: currency,
|
||||
@@ -449,6 +512,16 @@ class AddSubscriptionController {
|
||||
eventPrice: eventPrice,
|
||||
);
|
||||
|
||||
// 자동 보정이 발생했으면 안내
|
||||
if (adjustedNext.isAfter(originalDateOnly)) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showInfo(
|
||||
context: context,
|
||||
message: '다음 결제 예정일로 저장됨',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, true); // 성공 여부 반환
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../services/sms_scan/subscription_converter.dart';
|
||||
import '../services/sms_scan/subscription_filter.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../utils/logger.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
@@ -57,6 +59,33 @@ class SmsScanController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Android에서 SMS 권한 확인 및 요청
|
||||
final ctx = context;
|
||||
if (!kIsWeb) {
|
||||
final smsStatus = await permission.Permission.sms.status;
|
||||
if (!smsStatus.isGranted) {
|
||||
if (smsStatus.isPermanentlyDenied) {
|
||||
// 설정 유도 다이얼로그 표시
|
||||
if (!ctx.mounted) return;
|
||||
await _showPermissionSettingsDialog(ctx);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final req = await permission.Permission.sms.request();
|
||||
if (!ctx.mounted) return;
|
||||
if (!req.isGranted) {
|
||||
// 거부됨: 안내 후 종료
|
||||
if (!ctx.mounted) return;
|
||||
_errorMessage = AppLocalizations.of(ctx).smsPermissionRequired;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 스캔 실행
|
||||
Log.i('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
@@ -139,6 +168,30 @@ class SmsScanController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPermissionSettingsDialog(BuildContext context) async {
|
||||
final loc = AppLocalizations.of(context);
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(loc.smsPermissionRequired),
|
||||
content: Text(loc.permanentlyDeniedMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(loc.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await permission.openAppSettings();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(loc.openSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addCurrentSubscription(BuildContext context) async {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) return;
|
||||
|
||||
|
||||
10
scripts/generate_icons.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUT_DIR="assets/app_icon/house_check"
|
||||
SIZES=(1024 512 256 192 128 96 64 48 32)
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
python3 scripts/render_icon.py "$OUT_DIR" "${SIZES[@]}"
|
||||
|
||||
echo "\n아이콘 생성 완료: $OUT_DIR"
|
||||
236
scripts/render_icon.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate squircle app icons with a house + check mark glyph.
|
||||
|
||||
No external dependencies. Writes PNGs using zlib and CRC via stdlib.
|
||||
|
||||
Usage:
|
||||
python3 scripts/render_icon.py assets/app_icon/house_check 1024 512 256 192 128 96 64 48 32
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
import struct
|
||||
import zlib
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
NAVY = (0x0F, 0x17, 0x2A, 255) # #0F172A
|
||||
WHITE = (255, 255, 255, 255)
|
||||
GREEN = (0x10, 0xB9, 0x81, 255) # #10B981
|
||||
|
||||
|
||||
def srgb_to_lin(c: float) -> float:
|
||||
c = c / 255.0
|
||||
if c <= 0.04045:
|
||||
return c / 12.92
|
||||
return ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def lin_to_srgb(c: float) -> int:
|
||||
if c <= 0.0031308:
|
||||
v = 12.92 * c
|
||||
else:
|
||||
v = 1.055 * (c ** (1.0 / 2.4)) - 0.055
|
||||
return max(0, min(255, int(round(v * 255.0))))
|
||||
|
||||
|
||||
def over(dst: Tuple[float, float, float, float], src: Tuple[float, float, float, float]):
|
||||
# dst, src in linear space RGBA (0..1)
|
||||
dr, dg, db, da = dst
|
||||
sr, sg, sb, sa = src
|
||||
out_a = sa + da * (1.0 - sa)
|
||||
if out_a == 0.0:
|
||||
return (0.0, 0.0, 0.0, 0.0)
|
||||
out_r = (sr * sa + dr * da * (1.0 - sa)) / out_a
|
||||
out_g = (sg * sa + dg * da * (1.0 - sa)) / out_a
|
||||
out_b = (sb * sa + db * da * (1.0 - sa)) / out_a
|
||||
return (out_r, out_g, out_b, out_a)
|
||||
|
||||
|
||||
def to_lin_rgba(color: Tuple[int, int, int, int], alpha_scale: float = 1.0):
|
||||
r, g, b, a = color
|
||||
return (
|
||||
srgb_to_lin(r),
|
||||
srgb_to_lin(g),
|
||||
srgb_to_lin(b),
|
||||
(a / 255.0) * alpha_scale,
|
||||
)
|
||||
|
||||
|
||||
def point_in_triangle(px, py, ax, ay, bx, by, cx, cy) -> bool:
|
||||
# Barycentric technique
|
||||
v0x, v0y = cx - ax, cy - ay
|
||||
v1x, v1y = bx - ax, by - ay
|
||||
v2x, v2y = px - ax, py - ay
|
||||
dot00 = v0x * v0x + v0y * v0y
|
||||
dot01 = v0x * v1x + v0y * v1y
|
||||
dot02 = v0x * v2x + v0y * v2y
|
||||
dot11 = v1x * v1x + v1y * v1y
|
||||
dot12 = v1x * v2x + v1y * v2y
|
||||
denom = dot00 * dot11 - dot01 * dot01
|
||||
if denom == 0:
|
||||
return False
|
||||
inv = 1.0 / denom
|
||||
u = (dot11 * dot02 - dot01 * dot12) * inv
|
||||
v = (dot00 * dot12 - dot01 * dot02) * inv
|
||||
return (u >= 0) and (v >= 0) and (u + v <= 1)
|
||||
|
||||
|
||||
def point_in_rect(px, py, x0, y0, x1, y1) -> bool:
|
||||
return (x0 <= px <= x1) and (y0 <= py <= y1)
|
||||
|
||||
|
||||
def dist_point_to_segment(px, py, ax, ay, bx, by) -> float:
|
||||
abx, aby = bx - ax, by - ay
|
||||
apx, apy = px - ax, py - ay
|
||||
ab2 = abx * abx + aby * aby
|
||||
if ab2 == 0:
|
||||
dx, dy = px - ax, py - ay
|
||||
return math.hypot(dx, dy)
|
||||
t = (apx * abx + apy * aby) / ab2
|
||||
if t < 0:
|
||||
t = 0
|
||||
elif t > 1:
|
||||
t = 1
|
||||
hx, hy = ax + t * abx, ay + t * aby
|
||||
dx, dy = px - hx, py - hy
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
|
||||
def inside_superellipse(u: float, v: float, n: float = 4.5) -> bool:
|
||||
# map [0,1] -> [-1,1]
|
||||
x = 2.0 * u - 1.0
|
||||
y = 2.0 * v - 1.0
|
||||
return (abs(x) ** n + abs(y) ** n) <= 1.0
|
||||
|
||||
|
||||
def render_icon(size: int) -> bytes:
|
||||
# 2x2 supersampling
|
||||
samples = [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
|
||||
|
||||
# House geometry (normalized 0..1)
|
||||
roof = (0.28, 0.46, 0.50, 0.30, 0.72, 0.46) # (ax,ay,bx,by,cx,cy)
|
||||
body = (0.32, 0.46, 0.68, 0.76) # (x0,y0,x1,y1)
|
||||
|
||||
# Check path
|
||||
chk_a = (0.33, 0.60)
|
||||
chk_b = (0.46, 0.74)
|
||||
chk_c = (0.72, 0.48)
|
||||
thickness = 0.08 # relative to width
|
||||
|
||||
lin_bg = to_lin_rgba(NAVY)
|
||||
lin_white = to_lin_rgba(WHITE)
|
||||
lin_green = to_lin_rgba(GREEN)
|
||||
|
||||
out = bytearray(size * size * 4)
|
||||
idx = 0
|
||||
for j in range(size):
|
||||
for i in range(size):
|
||||
# Accumulate in linear
|
||||
dst = (0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
cov_bg = 0.0
|
||||
cov_house = 0.0
|
||||
cov_check = 0.0
|
||||
for (ox, oy) in samples:
|
||||
u = (i + ox) / size
|
||||
v = (j + oy) / size
|
||||
|
||||
if inside_superellipse(u, v):
|
||||
cov_bg += 1.0
|
||||
|
||||
hx = 0
|
||||
# house coverage (triangle or rect)
|
||||
if point_in_triangle(u, v, *roof):
|
||||
hx = 1
|
||||
elif point_in_rect(u, v, *body):
|
||||
hx = 1
|
||||
cov_house += hx
|
||||
|
||||
# check coverage by distance to segments
|
||||
d1 = dist_point_to_segment(u, v, *chk_a, *chk_b)
|
||||
d2 = dist_point_to_segment(u, v, *chk_b, *chk_c)
|
||||
if min(d1, d2) <= (thickness * 0.5):
|
||||
cov_check += 1.0
|
||||
|
||||
ss = float(len(samples))
|
||||
if cov_bg > 0.0:
|
||||
dst = over(dst, (lin_bg[0], lin_bg[1], lin_bg[2], lin_bg[3] * (cov_bg / ss)))
|
||||
if cov_house > 0.0:
|
||||
dst = over(dst, (lin_white[0], lin_white[1], lin_white[2], lin_white[3] * (cov_house / ss)))
|
||||
if cov_check > 0.0:
|
||||
dst = over(dst, (lin_green[0], lin_green[1], lin_green[2], lin_green[3] * (cov_check / ss)))
|
||||
|
||||
r = lin_to_srgb(dst[0])
|
||||
g = lin_to_srgb(dst[1])
|
||||
b = lin_to_srgb(dst[2])
|
||||
a = max(0, min(255, int(round(dst[3] * 255.0))))
|
||||
|
||||
out[idx + 0] = r
|
||||
out[idx + 1] = g
|
||||
out[idx + 2] = b
|
||||
out[idx + 3] = a
|
||||
idx += 4
|
||||
|
||||
return png_encode(size, size, bytes(out))
|
||||
|
||||
|
||||
def png_chunk(typ: bytes, data: bytes) -> bytes:
|
||||
return struct.pack(
|
||||
">I", len(data)
|
||||
) + typ + data + struct.pack(
|
||||
">I", (zlib.crc32(typ + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
|
||||
def png_encode(width: int, height: int, rgba: bytes) -> bytes:
|
||||
# Build raw scanlines with filter 0
|
||||
stride = width * 4
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0) # filter type 0
|
||||
start = y * stride
|
||||
raw.extend(rgba[start : start + stride])
|
||||
comp = zlib.compress(bytes(raw), level=9)
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(
|
||||
">IIBBBBB",
|
||||
width,
|
||||
height,
|
||||
8, # bit depth
|
||||
6, # color type RGBA
|
||||
0, # compression
|
||||
0, # filter
|
||||
0, # interlace
|
||||
)
|
||||
out = bytearray()
|
||||
out.extend(sig)
|
||||
out.extend(png_chunk(b"IHDR", ihdr))
|
||||
out.extend(png_chunk(b"IDAT", comp))
|
||||
out.extend(png_chunk(b"IEND", b""))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
"Usage: python3 scripts/render_icon.py <out_dir> <sizes...>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
out_dir = sys.argv[1]
|
||||
sizes = [int(s) for s in sys.argv[2:]]
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for s in sizes:
|
||||
data = render_icon(s)
|
||||
path = os.path.join(out_dir, f"{s}.png")
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"wrote {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||