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:
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user