Major UI/UX and architecture improvements

- Implemented new navigation system with NavigationProvider and route management
- Added adaptive theme system with ThemeProvider for better theme handling
- Introduced glassmorphism design elements (app bars, scaffolds, cards)
- Added advanced animations (spring animations, page transitions, staggered lists)
- Implemented performance optimizations (memory manager, lazy loading)
- Refactored Analysis screen into modular components
- Added floating navigation bar with haptic feedback
- Improved subscription cards with swipe actions
- Enhanced skeleton loading with better animations
- Added cached network image support
- Improved overall app architecture and code organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-10 18:36:57 +09:00
parent 8619e96739
commit 4731288622
55 changed files with 8219 additions and 2149 deletions

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart';
import 'package:provider/provider.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart';
import '../theme/app_colors.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -100,8 +106,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty &&
filteredSubscriptions[0] != null) {
if (filteredSubscriptions.isNotEmpty) {
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
@@ -163,10 +168,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
if (scannedSub == null) {
print('_filterDuplicates: null 구독 객체 발견');
return false;
}
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
@@ -189,10 +190,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
if (subscription == null) {
print('_filterDuplicates: null 구독 객체 무시');
continue;
}
String? websiteUrl = subscription.websiteUrl;
@@ -252,11 +249,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
_moveToNextSubscription();
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
@@ -365,9 +357,38 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${subscription.serviceName} 구독이 추가되었습니다.',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: const Color(0xFF10B981), // 초록색
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
dismissDirection: DismissDirection.horizontal,
),
);
}
@@ -402,21 +423,36 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
// 모든 구독을 처리했으면 화면 종료
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
Navigator.of(context).pop(true);
_navigateToHome();
}
});
}
// 홈 화면으로 이동
void _navigateToHome() {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('모든 구독이 처리되었습니다.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length ||
_scannedSubscriptions[_currentIndex] == null) {
if (_currentIndex >= _scannedSubscriptions.length) {
return '다음 결제일 확인 필요';
}
@@ -485,17 +521,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SMS 스캔'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState())),
return Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
);
}
@@ -507,9 +539,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('SMS 메시지를 스캔 중입니다...'),
ThemedText('SMS 메시지를 스캔 중입니다...'),
SizedBox(height: 8),
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7),
],
),
);
@@ -524,24 +556,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
child: ThemedText(
_errorMessage!,
style: const TextStyle(color: Colors.red),
color: Colors.red,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const Text(
const ThemedText(
'2회 이상 결제된 구독 서비스 찾기',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
fontSize: 20,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
child: ThemedText(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
opacity: 0.7,
),
),
const SizedBox(height: 32),
@@ -562,26 +595,11 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
return const Center(
child: Text('모든 구독 처리 완료'),
child: ThemedText('모든 구독 처리 완료'),
);
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류: 구독 정보를 불러올 수 없습니다.'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _moveToNextSubscription,
child: Text('건너뛰기'),
),
],
),
);
}
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
@@ -594,54 +612,42 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withOpacity(0.2),
backgroundColor: Colors.grey.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
Text(
ThemedText(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 24),
// 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
GlassmorphismCard(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'다음 구독을 찾았습니다',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
fontSize: 18,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 24),
// 서비스명
const Text(
const ThemedText(
'서비스명',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.serviceName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
fontSize: 22,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
@@ -652,15 +658,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'월 비용',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
@@ -672,10 +676,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
symbol: '',
decimalDigits: 0,
).format(subscription.monthlyCost),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
fontSize: 18,
fontWeight: FontWeight.bold,
),
],
),
@@ -684,21 +686,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'반복 횟수',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
_getRepeatCountText(subscription.repeatCount),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
],
),
@@ -714,20 +712,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'결제 주기',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
fontSize: 16,
fontWeight: FontWeight.w500,
),
],
),
@@ -736,20 +730,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'결제일',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
fontSize: 14,
fontWeight: FontWeight.w500,
),
],
),
@@ -800,7 +790,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
],
),
),
),
],
);
}
@@ -809,8 +798,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length &&
_scannedSubscriptions[_currentIndex] != null) {
_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;