diff --git a/CLAUDE.md b/CLAUDE.md index 92bd1e8..775a272 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,53 @@ Before starting any task, you MUST respond in the following format: - Follow **Givenโ€“Whenโ€“Then** structure - Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures +## ๐Ÿ“ Git Commit Guidelines + +### Commit Message Format + +- **Use clear, descriptive commit messages in Korean** +- **Follow conventional commit format**: `type: description` +- **Keep commit messages concise and focused** +- **DO NOT include Claude Code attribution or co-author tags** + +### Commit Message Structure + +``` +type: brief description in Korean + +Optional detailed explanation if needed +``` + +### Commit Types + +- `feat`: ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ์ถ”๊ฐ€ +- `fix`: ๋ฒ„๊ทธ ์ˆ˜์ • +- `refactor`: ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง +- `style`: ์ฝ”๋“œ ์Šคํƒ€์ผ ๋ณ€๊ฒฝ (formatting, missing semi-colons, etc) +- `docs`: ๋ฌธ์„œ ๋ณ€๊ฒฝ +- `test`: ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€ ๋˜๋Š” ์ˆ˜์ • +- `chore`: ๋นŒ๋“œ ํ”„๋กœ์„ธ์Šค ๋˜๋Š” ๋ณด์กฐ ๋„๊ตฌ ๋ณ€๊ฒฝ + +### Examples + +โœ… **Good Examples:** +- `feat: ์›”๋ณ„ ์ฐจํŠธ ๋‹ค๊ตญ์–ด ์ง€์› ์ถ”๊ฐ€` +- `fix: ๋ถ„์„ํ™”๋ฉด ์ด์ง€์ถœ ๊ธˆ์•ก ๋ถˆ์ผ์น˜ ๋ฌธ์ œ ํ•ด๊ฒฐ` +- `refactor: ํ†ตํ™” ๋ณ€ํ™˜ ๋กœ์ง ๋ชจ๋“ˆํ™”` + +โŒ **Avoid These:** +- Including "๐Ÿค– Generated with [Claude Code](https://claude.ai/code)" +- Including "Co-Authored-By: Claude " +- Vague messages like "update code" or "fix stuff" +- English commit messages (use Korean) + +### Critical Rules + +- **NEVER include AI tool attribution in commit messages** +- **Focus on what was changed and why** +- **Use present tense and imperative mood** +- **Keep the first line under 50 characters when possible** + ## ๐Ÿง  Error Analysis & Rule Documentation ### Mandatory Process When Errors Occur diff --git a/assets/data/text.json b/assets/data/text.json index 07ee755..7fd9c24 100644 --- a/assets/data/text.json +++ b/assets/data/text.json @@ -215,7 +215,8 @@ "amountRequired": "Please enter amount", "subscriptionDetail": "Subscription Detail", "enterAmount": "Enter amount", - "invalidAmount": "Please enter a valid amount" + "invalidAmount": "Please enter a valid amount", + "featureComingSoon": "This feature is coming soon" }, "ko": { "appTitle": "๋””์ง€ํ„ธ ์›”์„ธ ๊ด€๋ฆฌ์ž", @@ -433,7 +434,8 @@ "amountRequired": "๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”", "subscriptionDetail": "๊ตฌ๋… ์ƒ์„ธ", "enterAmount": "๊ธˆ์•ก์„ ์ž…๋ ฅํ•˜์„ธ์š”", - "invalidAmount": "์˜ฌ๋ฐ”๋ฅธ ๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”" + "invalidAmount": "์˜ฌ๋ฐ”๋ฅธ ๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”", + "featureComingSoon": "์ด ๊ธฐ๋Šฅ์€ ๊ณง ์ถœ์‹œ๋ฉ๋‹ˆ๋‹ค" }, "ja": { "appTitle": "ใƒ‡ใ‚ธใ‚ฟใƒซๆœˆ้ก็ฎก็†่€…", @@ -651,7 +653,8 @@ "amountRequired": "้‡‘้กใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„", "subscriptionDetail": "ใ‚ตใƒ–ใ‚นใ‚ฏใƒชใƒ—ใ‚ทใƒงใƒณ่ฉณ็ดฐ", "enterAmount": "้‡‘้กใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„", - "invalidAmount": "ๆญฃใ—ใ„้‡‘้กใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„" + "invalidAmount": "ๆญฃใ—ใ„้‡‘้กใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„", + "featureComingSoon": "ใ“ใฎๆฉŸ่ƒฝใฏ่ฟ‘ๆ—ฅๅ…ฌ้–‹ไบˆๅฎšใงใ™" }, "zh": { "appTitle": "ๆ•ฐๅญ—ๆœˆ็งŸ็ฎก็†ๅ™จ", @@ -869,6 +872,7 @@ "amountRequired": "่ฏท่พ“ๅ…ฅ้‡‘้ข", "subscriptionDetail": "่ฎข้˜…่ฏฆๆƒ…", "enterAmount": "่ฏท่พ“ๅ…ฅ้‡‘้ข", - "invalidAmount": "่ฏท่พ“ๅ…ฅๆœ‰ๆ•ˆ็š„้‡‘้ข" + "invalidAmount": "่ฏท่พ“ๅ…ฅๆœ‰ๆ•ˆ็š„้‡‘้ข", + "featureComingSoon": "ๆญคๅŠŸ่ƒฝๅณๅฐ†ๆŽจๅ‡บ" } } \ No newline at end of file diff --git a/lib/controllers/sms_scan_controller.dart b/lib/controllers/sms_scan_controller.dart new file mode 100644 index 0000000..7551cab --- /dev/null +++ b/lib/controllers/sms_scan_controller.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import '../services/sms_scanner.dart'; +import '../models/subscription.dart'; +import '../models/subscription_model.dart'; +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 '../providers/navigation_provider.dart'; +import '../providers/category_provider.dart'; +import '../l10n/app_localizations.dart'; + +class SmsScanController extends ChangeNotifier { + // ์ƒํƒœ ๊ด€๋ฆฌ + bool _isLoading = false; + bool get isLoading => _isLoading; + + String? _errorMessage; + String? get errorMessage => _errorMessage; + + List _scannedSubscriptions = []; + List get scannedSubscriptions => _scannedSubscriptions; + + int _currentIndex = 0; + int get currentIndex => _currentIndex; + + String? _selectedCategoryId; + String? get selectedCategoryId => _selectedCategoryId; + + final TextEditingController websiteUrlController = TextEditingController(); + + // ์˜์กด์„ฑ + final SmsScanner _smsScanner = SmsScanner(); + final SubscriptionConverter _converter = SubscriptionConverter(); + final SubscriptionFilter _filter = SubscriptionFilter(); + + @override + void dispose() { + websiteUrlController.dispose(); + super.dispose(); + } + + void setSelectedCategoryId(String? categoryId) { + _selectedCategoryId = categoryId; + notifyListeners(); + } + + void resetWebsiteUrl() { + websiteUrlController.text = ''; + } + + Future scanSms(BuildContext context) async { + _isLoading = true; + _errorMessage = null; + _scannedSubscriptions = []; + _currentIndex = 0; + notifyListeners(); + + try { + // SMS ์Šค์บ” ์‹คํ–‰ + print('SMS ์Šค์บ” ์‹œ์ž‘'); + final scannedSubscriptionModels = await _smsScanner.scanForSubscriptions(); + print('์Šค์บ”๋œ ๊ตฌ๋…: ${scannedSubscriptionModels.length}๊ฐœ'); + + if (scannedSubscriptionModels.isNotEmpty) { + print('์ฒซ ๋ฒˆ์งธ ๊ตฌ๋…: ${scannedSubscriptionModels[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${scannedSubscriptionModels[0].repeatCount}'); + } + + if (!context.mounted) return; + + if (scannedSubscriptionModels.isEmpty) { + print('์Šค์บ”๋œ ๊ตฌ๋…์ด ์—†์Œ'); + _errorMessage = AppLocalizations.of(context).subscriptionNotFound; + _isLoading = false; + notifyListeners(); + return; + } + + // SubscriptionModel์„ Subscription์œผ๋กœ ๋ณ€ํ™˜ + final scannedSubscriptions = _converter.convertModelsToSubscriptions(scannedSubscriptionModels); + + // 2ํšŒ ์ด์ƒ ๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…๋งŒ ํ•„ํ„ฐ๋ง + final repeatSubscriptions = _filter.filterByRepeatCount(scannedSubscriptions, 2); + print('๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…: ${repeatSubscriptions.length}๊ฐœ'); + + if (repeatSubscriptions.isNotEmpty) { + print('์ฒซ ๋ฒˆ์งธ ๋ฐ˜๋ณต ๊ตฌ๋…: ${repeatSubscriptions[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${repeatSubscriptions[0].repeatCount}'); + } + + if (repeatSubscriptions.isEmpty) { + print('๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…์ด ์—†์Œ'); + _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; + _isLoading = false; + notifyListeners(); + return; + } + + // ๊ตฌ๋… ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ + final provider = Provider.of(context, listen: false); + final existingSubscriptions = provider.subscriptions; + print('๊ธฐ์กด ๊ตฌ๋…: ${existingSubscriptions.length}๊ฐœ'); + + // ์ค‘๋ณต ๊ตฌ๋… ํ•„ํ„ฐ๋ง + final filteredSubscriptions = _filter.filterDuplicates(repeatSubscriptions, existingSubscriptions); + print('์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ๊ตฌ๋…: ${filteredSubscriptions.length}๊ฐœ'); + + if (filteredSubscriptions.isNotEmpty) { + print('์ฒซ ๋ฒˆ์งธ ํ•„ํ„ฐ๋ง๋œ ๊ตฌ๋…: ${filteredSubscriptions[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${filteredSubscriptions[0].repeatCount}'); + } + + // ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์‹ ๊ทœ ๊ตฌ๋…์ด ์—†๋Š” ๊ฒฝ์šฐ + if (filteredSubscriptions.isEmpty) { + print('์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์‹ ๊ทœ ๊ตฌ๋…์ด ์—†์Œ'); + _isLoading = false; + notifyListeners(); + return; + } + + _scannedSubscriptions = filteredSubscriptions; + _isLoading = false; + websiteUrlController.text = ''; // URL ์ž…๋ ฅ ํ•„๋“œ ์ดˆ๊ธฐํ™” + notifyListeners(); + } catch (e) { + print('SMS ์Šค์บ” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); + if (context.mounted) { + _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); + _isLoading = false; + notifyListeners(); + } + } + } + + Future addCurrentSubscription(BuildContext context) async { + if (_currentIndex >= _scannedSubscriptions.length) return; + + final subscription = _scannedSubscriptions[_currentIndex]; + + try { + final provider = Provider.of(context, listen: false); + final categoryProvider = Provider.of(context, listen: false); + + final finalCategoryId = _selectedCategoryId ?? subscription.category ?? getDefaultCategoryId(categoryProvider); + + // websiteUrl ์ฒ˜๋ฆฌ + final websiteUrl = websiteUrlController.text.trim().isNotEmpty + ? websiteUrlController.text.trim() + : subscription.websiteUrl; + + print('๊ตฌ๋… ์ถ”๊ฐ€ ์‹œ๋„: ${subscription.serviceName}, ์นดํ…Œ๊ณ ๋ฆฌ: $finalCategoryId, URL: $websiteUrl'); + + // addSubscription ํ˜ธ์ถœ + await provider.addSubscription( + serviceName: subscription.serviceName, + monthlyCost: subscription.monthlyCost, + billingCycle: subscription.billingCycle, + nextBillingDate: subscription.nextBillingDate, + websiteUrl: websiteUrl, + isAutoDetected: true, + repeatCount: subscription.repeatCount, + lastPaymentDate: subscription.lastPaymentDate, + categoryId: finalCategoryId, + currency: subscription.currency, + ); + + print('๊ตฌ๋… ์ถ”๊ฐ€ ์„ฑ๊ณต: ${subscription.serviceName}'); + + moveToNextSubscription(context); + } catch (e) { + print('๊ตฌ๋… ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); + // ์˜ค๋ฅ˜๊ฐ€ ์žˆ์–ด๋„ ๋‹ค์Œ ๊ตฌ๋…์œผ๋กœ ์ด๋™ + moveToNextSubscription(context); + } + } + + void skipCurrentSubscription(BuildContext context) { + final subscription = _scannedSubscriptions[_currentIndex]; + print('๊ตฌ๋… ๊ฑด๋„ˆ๋›ฐ๊ธฐ: ${subscription.serviceName}'); + moveToNextSubscription(context); + } + + void moveToNextSubscription(BuildContext context) { + _currentIndex++; + websiteUrlController.text = ''; // URL ์ž…๋ ฅ ํ•„๋“œ ์ดˆ๊ธฐํ™” + _selectedCategoryId = null; // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ์ดˆ๊ธฐํ™” + + // ๋ชจ๋“  ๊ตฌ๋…์„ ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + if (_currentIndex >= _scannedSubscriptions.length) { + navigateToHome(context); + } + + notifyListeners(); + } + + void navigateToHome(BuildContext context) { + // NavigationProvider๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + final navigationProvider = Provider.of(context, listen: false); + navigationProvider.updateCurrentIndex(0); + } + + void resetState() { + _scannedSubscriptions = []; + _currentIndex = 0; + _errorMessage = null; + notifyListeners(); + } + + String getDefaultCategoryId(CategoryProvider categoryProvider) { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name == 'other', + orElse: () => categoryProvider.categories.first, + ); + print('๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ์„ค์ •: ${otherCategory.name} (ID: ${otherCategory.id})'); + return otherCategory.id; + } + + void initializeWebsiteUrl() { + if (_currentIndex < _scannedSubscriptions.length) { + final currentSub = _scannedSubscriptions[_currentIndex]; + if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { + websiteUrlController.text = currentSub.websiteUrl!; + } + } + } +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 44b5bd8..d22b157 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -409,6 +409,7 @@ class AppLocalizations { String get subscriptionDetail => _localizedStrings['subscriptionDetail'] ?? 'Subscription Detail'; String get enterAmount => _localizedStrings['enterAmount'] ?? 'Enter amount'; String get invalidAmount => _localizedStrings['invalidAmount'] ?? 'Please enter a valid amount'; + String get featureComingSoon => _localizedStrings['featureComingSoon'] ?? 'This feature is coming soon'; // ๊ฒฐ์ œ ์ฃผ๊ธฐ๋ฅผ ํ‚ค๊ฐ’์œผ๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋ฒˆ์—ญ๋œ ์ด๋ฆ„ ๋ฐ˜ํ™˜ String getBillingCycleName(String billingCycleKey) { diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index a75da6a..39b5152 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -1,25 +1,11 @@ import 'package:flutter/material.dart'; -import '../services/sms_scanner.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/navigation_provider.dart'; -import '../providers/locale_provider.dart'; import 'package:provider/provider.dart'; -import '../models/subscription.dart'; -import '../models/subscription_model.dart'; -import '../services/subscription_url_matcher.dart'; -import '../services/currency_util.dart'; -import 'package:intl/intl.dart'; // NumberFormat์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ import ์ถ”๊ฐ€ -import '../widgets/glassmorphism_card.dart'; -import '../widgets/themed_text.dart'; -import '../theme/app_colors.dart'; +import '../controllers/sms_scan_controller.dart'; +import '../widgets/sms_scan/scan_loading_widget.dart'; +import '../widgets/sms_scan/scan_initial_widget.dart'; +import '../widgets/sms_scan/scan_progress_widget.dart'; +import '../widgets/sms_scan/subscription_card_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; -import '../widgets/common/buttons/primary_button.dart'; -import '../widgets/common/buttons/secondary_button.dart'; -import '../widgets/common/form_fields/base_text_field.dart'; -import '../providers/category_provider.dart'; -import '../models/category_model.dart'; -import '../widgets/common/form_fields/category_selector.dart'; -import '../widgets/native_ad_widget.dart'; import '../l10n/app_localizations.dart'; class SmsScanScreen extends StatefulWidget { @@ -30,581 +16,90 @@ class SmsScanScreen extends StatefulWidget { } class _SmsScanScreenState extends State { - bool _isLoading = false; - String? _errorMessage; - final SmsScanner _smsScanner = SmsScanner(); + late SmsScanController _controller; - // ์Šค์บ”ํ•œ ๊ตฌ๋… ๋ชฉ๋ก - List _scannedSubscriptions = []; - - // ํ˜„์žฌ ํ‘œ์‹œ ์ค‘์ธ ๊ตฌ๋… ์ธ๋ฑ์Šค - int _currentIndex = 0; - - // ์›น์‚ฌ์ดํŠธ URL ์ปจํŠธ๋กค๋Ÿฌ - final TextEditingController _websiteUrlController = TextEditingController(); - - // ์„ ํƒ๋œ ์นดํ…Œ๊ณ ๋ฆฌ ID ์ €์žฅ - String? _selectedCategoryId; + @override + void initState() { + super.initState(); + _controller = SmsScanController(); + _controller.addListener(_handleControllerUpdate); + } @override void dispose() { - _websiteUrlController.dispose(); + _controller.removeListener(_handleControllerUpdate); + _controller.dispose(); super.dispose(); } - // SMS ์Šค์บ” ์‹คํ–‰ - Future _scanSms() async { - setState(() { - _isLoading = true; - _errorMessage = null; - _scannedSubscriptions = []; - _currentIndex = 0; - }); - - try { - // SMS ์Šค์บ” ์‹คํ–‰ - print('SMS ์Šค์บ” ์‹œ์ž‘'); - final scannedSubscriptionModels = - await _smsScanner.scanForSubscriptions(); - print('์Šค์บ”๋œ ๊ตฌ๋…: ${scannedSubscriptionModels.length}๊ฐœ'); - - if (scannedSubscriptionModels.isNotEmpty) { - print( - '์ฒซ ๋ฒˆ์งธ ๊ตฌ๋…: ${scannedSubscriptionModels[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${scannedSubscriptionModels[0].repeatCount}'); - } - - if (!mounted) return; - - if (scannedSubscriptionModels.isEmpty) { - print('์Šค์บ”๋œ ๊ตฌ๋…์ด ์—†์Œ'); - setState(() { - _errorMessage = AppLocalizations.of(context).subscriptionNotFound; - _isLoading = false; - }); - return; - } - - // SubscriptionModel์„ Subscription์œผ๋กœ ๋ณ€ํ™˜ - final scannedSubscriptions = - _convertModelsToSubscriptions(scannedSubscriptionModels); - - // 2ํšŒ ์ด์ƒ ๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…๋งŒ ํ•„ํ„ฐ๋ง - final repeatSubscriptions = - scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList(); - print('๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…: ${repeatSubscriptions.length}๊ฐœ'); - - if (repeatSubscriptions.isNotEmpty) { - print( - '์ฒซ ๋ฒˆ์งธ ๋ฐ˜๋ณต ๊ตฌ๋…: ${repeatSubscriptions[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${repeatSubscriptions[0].repeatCount}'); - } - - if (repeatSubscriptions.isEmpty) { - print('๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋…์ด ์—†์Œ'); - setState(() { - _errorMessage = AppLocalizations.of(context).repeatSubscriptionNotFound; - _isLoading = false; - }); - return; - } - - // ๊ตฌ๋… ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ - final provider = - Provider.of(context, listen: false); - final existingSubscriptions = provider.subscriptions; - print('๊ธฐ์กด ๊ตฌ๋…: ${existingSubscriptions.length}๊ฐœ'); - - // ์ค‘๋ณต ๊ตฌ๋… ํ•„ํ„ฐ๋ง - final filteredSubscriptions = - _filterDuplicates(repeatSubscriptions, existingSubscriptions); - print('์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ๊ตฌ๋…: ${filteredSubscriptions.length}๊ฐœ'); - - if (filteredSubscriptions.isNotEmpty) { - print( - '์ฒซ ๋ฒˆ์งธ ํ•„ํ„ฐ๋ง๋œ ๊ตฌ๋…: ${filteredSubscriptions[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${filteredSubscriptions[0].repeatCount}'); - } - - // ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์‹ ๊ทœ ๊ตฌ๋…์ด ์—†๋Š” ๊ฒฝ์šฐ - if (filteredSubscriptions.isEmpty) { - print('์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ์‹ ๊ทœ ๊ตฌ๋…์ด ์—†์Œ'); - setState(() { - _isLoading = false; - }); - - // ์Šค๋‚ต๋ฐ”๋กœ ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - if (mounted) { - AppSnackBar.showInfo( - context: context, - message: AppLocalizations.of(context).newSubscriptionNotFound, - icon: Icons.search_off_rounded, - ); - } - - return; - } - - setState(() { - _scannedSubscriptions = filteredSubscriptions; - _isLoading = false; - _websiteUrlController.text = ''; // URL ์ž…๋ ฅ ํ•„๋“œ ์ดˆ๊ธฐํ™” - }); - } catch (e) { - print('SMS ์Šค์บ” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); - if (mounted) { - setState(() { - _errorMessage = AppLocalizations.of(context).smsScanErrorWithMessage(e.toString()); - _isLoading = false; - }); - } - } - } - - // SubscriptionModel ๋ฆฌ์ŠคํŠธ๋ฅผ Subscription ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ - List _convertModelsToSubscriptions( - List models) { - final result = []; - - for (var model in models) { - try { - // ๋ชจ๋ธ์˜ ํ•„๋“œ๊ฐ€ null์ธ ๊ฒฝ์šฐ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ - result.add(Subscription( - id: model.id, - serviceName: model.serviceName, - monthlyCost: model.monthlyCost, - billingCycle: model.billingCycle, - nextBillingDate: model.nextBillingDate, - category: model.categoryId, // categoryId๋ฅผ category๋กœ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋งคํ•‘ - repeatCount: model.repeatCount > 0 - ? model.repeatCount - : 1, // ๋ฐ˜๋ณต ํšŸ์ˆ˜๊ฐ€ 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ ๊ธฐ๋ณธ๊ฐ’ 1 ์‚ฌ์šฉ - lastPaymentDate: model.lastPaymentDate, - websiteUrl: model.websiteUrl, - currency: model.currency, // ํ†ตํ™” ๋‹จ์œ„ ์ •๋ณด ์ถ”๊ฐ€ - )); - - print( - '๋ชจ๋ธ ๋ณ€ํ™˜ ์„ฑ๊ณต: ${model.serviceName}, ์นดํ…Œ๊ณ ๋ฆฌID: ${model.categoryId}, URL: ${model.websiteUrl}, ํ†ตํ™”: ${model.currency}'); - } catch (e) { - print('๋ชจ๋ธ ๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); - } - } - - return result; - } - - // ์ค‘๋ณต ๊ตฌ๋… ํ•„ํ„ฐ๋ง (์„œ๋น„์Šค๋ช…๊ณผ ๊ธˆ์•ก์ด ๊ฐ™์œผ๋ฉด ์ค‘๋ณต์œผ๋กœ ๊ฐ„์ฃผ) - List _filterDuplicates( - List scanned, List existing) { - print( - '_filterDuplicates: ์Šค์บ”๋œ ๊ตฌ๋… ${scanned.length}๊ฐœ, ๊ธฐ์กด ๊ตฌ๋… ${existing.length}๊ฐœ'); - - // ์ค‘๋ณต๋˜์ง€ ์•Š์€ ๊ตฌ๋…๋งŒ ํ•„ํ„ฐ๋ง - final nonDuplicates = scanned.where((scannedSub) { - - // ์„œ๋น„์Šค๋ช…๊ณผ ๊ธˆ์•ก์ด ๋™์ผํ•œ ๊ธฐ์กด ๊ตฌ๋… ์ฐพ๊ธฐ - final hasDuplicate = existing.any((existingSub) => - existingSub.serviceName.toLowerCase() == - scannedSub.serviceName.toLowerCase() && - existingSub.monthlyCost == scannedSub.monthlyCost); - - if (hasDuplicate) { - print('_filterDuplicates: ์ค‘๋ณต ๋ฐœ๊ฒฌ - ${scannedSub.serviceName}'); - } - - // ์ค‘๋ณต์ด ์—†์œผ๋ฉด true ๋ฐ˜ํ™˜ - return !hasDuplicate; - }).toList(); - - print('_filterDuplicates: ์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ${nonDuplicates.length}๊ฐœ'); - - // ๊ฐ ๊ตฌ๋…์— ์›น์‚ฌ์ดํŠธ URL ์ž๋™ ๋งค์นญ ์‹œ๋„ - final result = []; - - for (int i = 0; i < nonDuplicates.length; i++) { - final subscription = nonDuplicates[i]; - - String? websiteUrl = subscription.websiteUrl; - - if (websiteUrl == null || websiteUrl.isEmpty) { - websiteUrl = - SubscriptionUrlMatcher.suggestUrl(subscription.serviceName); - print( - '_filterDuplicates: URL ์ž๋™ ๋งค์นญ ์‹œ๋„ - ${subscription.serviceName}, ๊ฒฐ๊ณผ: ${websiteUrl ?? "๋งค์นญ ์‹คํŒจ"}'); - } - - try { - // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ - if (subscription.serviceName.isEmpty) { - print('_filterDuplicates: ์„œ๋น„์Šค๋ช…์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.'); - continue; - } - - if (subscription.monthlyCost <= 0) { - print('_filterDuplicates: ์›” ๋น„์šฉ์ด 0 ์ดํ•˜์ž…๋‹ˆ๋‹ค. ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.'); - continue; - } - - // Subscription ๊ฐ์ฒด์— URL ์„ค์ • (์ƒˆ ๊ฐ์ฒด ์ƒ์„ฑ) - result.add(Subscription( - id: subscription.id, - serviceName: subscription.serviceName, - monthlyCost: subscription.monthlyCost, - billingCycle: subscription.billingCycle, - nextBillingDate: subscription.nextBillingDate, - category: subscription.category, - notes: subscription.notes, - repeatCount: - subscription.repeatCount > 0 ? subscription.repeatCount : 1, - lastPaymentDate: subscription.lastPaymentDate, - websiteUrl: websiteUrl, - currency: subscription.currency, // ํ†ตํ™” ๋‹จ์œ„ ์ •๋ณด ์ถ”๊ฐ€ - )); - - print( - '_filterDuplicates: URL ์„ค์ • - ${subscription.serviceName}, URL: ${websiteUrl ?? "์—†์Œ"}, ์นดํ…Œ๊ณ ๋ฆฌ: ${subscription.category ?? "์—†์Œ"}, ํ†ตํ™”: ${subscription.currency}'); - } catch (e) { - print('_filterDuplicates: ๊ตฌ๋… ๊ฐ์ฒด ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); - } - } - - print('_filterDuplicates: URL ์„ค์ • ์™„๋ฃŒ, ์ตœ์ข… ${result.length}๊ฐœ ๊ตฌ๋…'); - return result; - } - - // ํ˜„์žฌ ๊ตฌ๋… ์ถ”๊ฐ€ - Future _addCurrentSubscription() async { - if (_scannedSubscriptions.isEmpty || - _currentIndex >= _scannedSubscriptions.length) { - print( - '์˜ค๋ฅ˜: ์ธ๋ฑ์Šค๊ฐ€ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚ฌ์Šต๋‹ˆ๋‹ค. (index: $_currentIndex, size: ${_scannedSubscriptions.length})'); - return; - } - - final subscription = _scannedSubscriptions[_currentIndex]; - - final provider = Provider.of(context, listen: false); - - // ๋‚ ์งœ๊ฐ€ ๊ณผ๊ฑฐ๋ฉด ๋‹ค์Œ ๊ฒฐ์ œ์ผ์„ ์กฐ์ • - final now = DateTime.now(); - DateTime nextBillingDate = subscription.nextBillingDate; - - if (nextBillingDate.isBefore(now)) { - // ์ฃผ๊ธฐ์— ๋”ฐ๋ผ ๋‹ค์Œ ๊ฒฐ์ œ์ผ ์กฐ์ • - if (subscription.billingCycle == '์›”๊ฐ„') { - // ํ˜„์žฌ ๋‹ฌ์˜ ๊ฒฐ์ œ์ผ - int day = nextBillingDate.day; - // ํ˜„์žฌ ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final lastDay = DateTime(now.year, now.month + 1, 0).day; - if (day > lastDay) { - day = lastDay; - } - - DateTime adjustedDate = DateTime(now.year, now.month, day); - - // ํ˜„์žฌ ๋‚ ์งœ๋ณด๋‹ค ์ด์ „์ด๋ผ๋ฉด ๋‹ค์Œ ๋‹ฌ๋กœ ์„ค์ • - if (adjustedDate.isBefore(now)) { - // ๋‹ค์Œ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day; - if (day > nextMonthLastDay) { - day = nextMonthLastDay; - } - adjustedDate = DateTime(now.year, now.month + 1, day); - } - - nextBillingDate = adjustedDate; - } else if (subscription.billingCycle == '์—ฐ๊ฐ„') { - // ํ˜„์žฌ ๋…„๋„์˜ ๊ฒฐ์ œ์ผ - int day = nextBillingDate.day; - // ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day; - if (day > lastDay) { - day = lastDay; - } - - DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day); - - // ํ˜„์žฌ ๋‚ ์งœ๋ณด๋‹ค ์ด์ „์ด๋ผ๋ฉด ๋‹ค์Œ ํ•ด๋กœ ์„ค์ • - if (adjustedDate.isBefore(now)) { - // ๋‹ค์Œ ํ•ด ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final nextYearLastDay = - DateTime(now.year + 1, nextBillingDate.month + 1, 0).day; - if (day > nextYearLastDay) { - day = nextYearLastDay; - } - adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day); - } - - nextBillingDate = adjustedDate; - } else if (subscription.billingCycle == '์ฃผ๊ฐ„') { - // ํ˜„์žฌ ๋‚ ์งœ์—์„œ ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๋‹ค์Œ ์ฃผ ๊ฐ™์€ ์š”์ผ - final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7; - nextBillingDate = - now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext)); - } - } - - // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ์œผ๋ฉด ์ž๋™ ๋งค์นญ ์‹œ๋„ - String? websiteUrl = _websiteUrlController.text.trim(); - if (websiteUrl.isEmpty && subscription.websiteUrl != null) { - websiteUrl = subscription.websiteUrl; - print('๊ตฌ๋… ์ถ”๊ฐ€: ๊ธฐ์กด URL ์‚ฌ์šฉ - ${websiteUrl ?? "์—†์Œ"}'); - } else if (websiteUrl.isEmpty) { - try { - websiteUrl = - SubscriptionUrlMatcher.suggestUrl(subscription.serviceName); - print( - '๊ตฌ๋… ์ถ”๊ฐ€: URL ์ž๋™ ๋งค์นญ - ${subscription.serviceName} -> ${websiteUrl ?? "๋งค์นญ ์‹คํŒจ"}'); - } catch (e) { - print('๊ตฌ๋… ์ถ”๊ฐ€: URL ์ž๋™ ๋งค์นญ ์‹คํŒจ - $e'); - websiteUrl = null; - } - } else { - print('๊ตฌ๋… ์ถ”๊ฐ€: ์‚ฌ์šฉ์ž ์ž…๋ ฅ URL ์‚ฌ์šฉ - $websiteUrl'); - } - - try { - print( - '๊ตฌ๋… ์ถ”๊ฐ€ ์‹œ๋„ - ์„œ๋น„์Šค๋ช…: ${subscription.serviceName}, ๋น„์šฉ: ${subscription.monthlyCost}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${subscription.repeatCount}'); - - // ๋ฐ˜๋ณต ํšŸ์ˆ˜๊ฐ€ 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ ๊ธฐ๋ณธ๊ฐ’ 1 ์‚ฌ์šฉ - final int safeRepeatCount = - subscription.repeatCount > 0 ? subscription.repeatCount : 1; - - // ์นดํ…Œ๊ณ ๋ฆฌ ์„ค์ • ๋กœ์ง - final categoryId = _selectedCategoryId ?? subscription.category ?? _getDefaultCategoryId(); - print('์นดํ…Œ๊ณ ๋ฆฌ ์„ค์ • - ์„ ํƒ๋œ: $_selectedCategoryId, ์ž๋™๋งค์นญ: ${subscription.category}, ์ตœ์ข…: $categoryId'); - - await provider.addSubscription( - serviceName: subscription.serviceName, - monthlyCost: subscription.monthlyCost, - billingCycle: subscription.billingCycle, - nextBillingDate: nextBillingDate, - websiteUrl: websiteUrl, - isAutoDetected: true, - repeatCount: safeRepeatCount, - lastPaymentDate: subscription.lastPaymentDate, - categoryId: categoryId, - currency: subscription.currency, // ํ†ตํ™” ๋‹จ์œ„ ์ •๋ณด ์ถ”๊ฐ€ - ); - - print('๊ตฌ๋… ์ถ”๊ฐ€ ์„ฑ๊ณต'); - - // ์„ฑ๊ณต ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - if (mounted) { - AppSnackBar.showSuccess( - context: context, - message: AppLocalizations.of(context).subscriptionAddedWithName(subscription.serviceName), - ); - } - - // ๋‹ค์Œ ๊ตฌ๋…์œผ๋กœ ์ด๋™ - _moveToNextSubscription(); - } catch (e) { - print('๊ตฌ๋… ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); - if (mounted) { - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context).subscriptionAddErrorWithMessage(e.toString()), - ); - - // ์˜ค๋ฅ˜๊ฐ€ ์žˆ์–ด๋„ ๋‹ค์Œ ๊ตฌ๋…์œผ๋กœ ์ด๋™ - _moveToNextSubscription(); - } - } - } - - // ํ˜„์žฌ ๊ตฌ๋… ๊ฑด๋„ˆ๋›ฐ๊ธฐ - void _skipCurrentSubscription() { - final subscription = _scannedSubscriptions[_currentIndex]; - + void _handleControllerUpdate() { if (mounted) { - AppSnackBar.showInfo( - context: context, - message: AppLocalizations.of(context).subscriptionSkipped(subscription.serviceName), - icon: Icons.skip_next_rounded, + setState(() {}); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller.initializeWebsiteUrl(); + } + + Widget _buildContent() { + if (_controller.isLoading) { + return const ScanLoadingWidget(); + } + + if (_controller.scannedSubscriptions.isEmpty) { + return ScanInitialWidget( + onScanPressed: () => _controller.scanSms(context), + errorMessage: _controller.errorMessage, ); } - - _moveToNextSubscription(); - } - // ๋‹ค์Œ ๊ตฌ๋…์œผ๋กœ ์ด๋™ - void _moveToNextSubscription() { - setState(() { - _currentIndex++; - _websiteUrlController.text = ''; // URL ์ž…๋ ฅ ํ•„๋“œ ์ดˆ๊ธฐํ™” - _selectedCategoryId = null; // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ์ดˆ๊ธฐํ™” - - // ๋ชจ๋“  ๊ตฌ๋…์„ ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - if (_currentIndex >= _scannedSubscriptions.length) { - _navigateToHome(); - } - }); - } - - // ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - void _navigateToHome() { - // NavigationProvider๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - final navigationProvider = Provider.of(context, listen: false); - navigationProvider.updateCurrentIndex(0); - - // ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - AppSnackBar.showSuccess( - context: context, - message: AppLocalizations.of(context).allSubscriptionsProcessed, - ); - } - - // ๋‚ ์งœ ์ƒํƒœ ํ…์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ - String _getNextBillingText(DateTime date) { - final now = DateTime.now(); - - if (date.isBefore(now)) { - // ์ฃผ๊ธฐ์— ๋”ฐ๋ผ ๋‹ค์Œ ๊ฒฐ์ œ์ผ ์˜ˆ์ธก - if (_currentIndex >= _scannedSubscriptions.length) { - return '๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ™•์ธ ํ•„์š”'; - } - - final subscription = _scannedSubscriptions[_currentIndex]; - if (subscription.billingCycle == '์›”๊ฐ„') { - // ์ด๋ฒˆ ๋‹ฌ ๋˜๋Š” ๋‹ค์Œ ๋‹ฌ ๊ฐ™์€ ๋‚ ์งœ - int day = date.day; - // ํ˜„์žฌ ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final lastDay = DateTime(now.year, now.month + 1, 0).day; - if (day > lastDay) { - day = lastDay; + // ๋ชจ๋“  ๊ตฌ๋… ์ฒ˜๋ฆฌ ์™„๋ฃŒ ํ™•์ธ + if (_controller.currentIndex >= _controller.scannedSubscriptions.length) { + // ์ค‘๋ณต ์Šค๋‚ต๋ฐ” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ๋ฐ”๋กœ ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _controller.scannedSubscriptions.isNotEmpty) { + AppSnackBar.showSuccess( + context: context, + message: AppLocalizations.of(context).allSubscriptionsProcessed, + ); + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + _controller.resetState(); } - - DateTime adjusted = DateTime(now.year, now.month, day); - if (adjusted.isBefore(now)) { - // ๋‹ค์Œ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day; - if (day > nextMonthLastDay) { - day = nextMonthLastDay; - } - adjusted = DateTime(now.year, now.month + 1, day); - } - - final daysUntil = adjusted.difference(now).inDays; - return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil); - } else if (subscription.billingCycle == '์—ฐ๊ฐ„') { - // ์˜ฌํ•ด ๋˜๋Š” ๋‚ด๋…„ ๊ฐ™์€ ๋‚ ์งœ - int day = date.day; - // ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final lastDay = DateTime(now.year, date.month + 1, 0).day; - if (day > lastDay) { - day = lastDay; - } - - DateTime adjusted = DateTime(now.year, date.month, day); - if (adjusted.isBefore(now)) { - // ๋‹ค์Œ ํ•ด ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • - final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day; - if (day > nextYearLastDay) { - day = nextYearLastDay; - } - adjusted = DateTime(now.year + 1, date.month, day); - } - - final daysUntil = adjusted.difference(now).inDays; - return AppLocalizations.of(context).nextBillingDateEstimated(AppLocalizations.of(context).formatDate(adjusted), daysUntil); - } else { - return '๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ™•์ธ ํ•„์š” (๊ณผ๊ฑฐ ๋‚ ์งœ)'; - } - } else { - // ๋ฏธ๋ž˜ ๋‚ ์งœ์ธ ๊ฒฝ์šฐ - final daysUntil = date.difference(now).inDays; - return AppLocalizations.of(context).nextBillingDateInfo(AppLocalizations.of(context).formatDate(date), daysUntil); + }); + return ScanInitialWidget( + onScanPressed: () => _controller.scanSms(context), + errorMessage: _controller.errorMessage, + ); } - } - // ๋‚ ์งœ ํฌ๋งท ํ•จ์ˆ˜ - String _formatDate(DateTime date) { - return '${date.year}๋…„ ${date.month}์›” ${date.day}์ผ'; - } + final currentSubscription = _controller.scannedSubscriptions[_controller.currentIndex]; - // ๊ฒฐ์ œ ๋ฐ˜๋ณต ํšŸ์ˆ˜ ํ…์ŠคํŠธ - String _getRepeatCountText(int count) { - return AppLocalizations.of(context).repeatCountDetected(count); - } - - // ์นดํ…Œ๊ณ ๋ฆฌ ์นฉ ๋นŒ๋“œ - Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) { - final category = categoryId != null - ? categoryProvider.getCategoryById(categoryId) - : null; - - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๊ธฐํƒ€ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - final defaultCategory = category ?? categoryProvider.categories.firstWhere( - (cat) => cat.name == 'other', - orElse: () => categoryProvider.categories.first, - ); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppColors.navyGray.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // ์นดํ…Œ๊ณ ๋ฆฌ ์•„์ด์ฝ˜ ํ‘œ์‹œ - Icon( - _getCategoryIcon(defaultCategory), - size: 16, - color: AppColors.darkNavy, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ScanProgressWidget( + currentIndex: _controller.currentIndex, + totalCount: _controller.scannedSubscriptions.length, ), - const SizedBox(width: 6), - ThemedText( - categoryProvider.getLocalizedCategoryName(context, defaultCategory.name), - fontSize: 14, - fontWeight: FontWeight.w500, - forceDark: true, - ), - ], - ), + ), + const SizedBox(height: 24), + SubscriptionCardWidget( + subscription: currentSubscription, + websiteUrlController: _controller.websiteUrlController, + selectedCategoryId: _controller.selectedCategoryId, + onCategoryChanged: _controller.setSelectedCategoryId, + onAdd: () => _controller.addCurrentSubscription(context), + onSkip: () => _controller.skipCurrentSubscription(context), + ), + ], ); } - - // ์นดํ…Œ๊ณ ๋ฆฌ ์•„์ด์ฝ˜ ๋ฐ˜ํ™˜ - IconData _getCategoryIcon(CategoryModel category) { - switch (category.name) { - case 'music': - return Icons.music_note_rounded; - case 'ottVideo': - return Icons.movie_filter_rounded; - case 'storageCloud': - return Icons.cloud_outlined; - case 'telecomInternetTv': - return Icons.wifi_rounded; - case 'lifestyle': - return Icons.home_outlined; - case 'shoppingEcommerce': - return Icons.shopping_cart_outlined; - case 'programming': - return Icons.code_rounded; - case 'collaborationOffice': - return Icons.business_center_outlined; - case 'aiService': - return Icons.smart_toy_outlined; - case 'other': - default: - return Icons.category_outlined; - } - } - - // ๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ID (๊ธฐํƒ€) ๋ฐ˜ํ™˜ - String _getDefaultCategoryId() { - final categoryProvider = Provider.of(context, listen: false); - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name == 'other', - orElse: () => categoryProvider.categories.first, // ๋งŒ์•ฝ "๊ธฐํƒ€"๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ - ); - print('๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ ์„ค์ •: ${otherCategory.name} (ID: ${otherCategory.id})'); - return otherCategory.id; - } - @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -615,11 +110,7 @@ class _SmsScanScreenState extends State { SizedBox( height: kToolbarHeight + MediaQuery.of(context).padding.top, ), - _isLoading - ? _buildLoadingState() - : (_scannedSubscriptions.isEmpty - ? _buildInitialState() - : _buildSubscriptionState()), + _buildContent(), // FloatingNavigationBar๋ฅผ ์œ„ํ•œ ์ถฉ๋ถ„ํ•œ ํ•˜๋‹จ ์—ฌ๋ฐฑ SizedBox( height: 120 + MediaQuery.of(context).padding.bottom, @@ -628,310 +119,4 @@ class _SmsScanScreenState extends State { ), ); } - - // ๋กœ๋”ฉ ์ƒํƒœ UI - Widget _buildLoadingState() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), - ), - const SizedBox(height: 16), - ThemedText(AppLocalizations.of(context).scanningMessages, forceDark: true), - const SizedBox(height: 8), - ThemedText(AppLocalizations.of(context).findingSubscriptions, opacity: 0.7, forceDark: true), - ], - ), - ), - ); - } - - // ์ดˆ๊ธฐ ์ƒํƒœ UI - Widget _buildInitialState() { - return Column( - children: [ - // ๊ด‘๊ณ  ์œ„์ ฏ ์ถ”๊ฐ€ - const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), - const SizedBox(height: 48), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: ThemedText( - _errorMessage!, - color: Colors.red, - textAlign: TextAlign.center, - ), - ), - ThemedText( - AppLocalizations.of(context).findRepeatSubscriptions, - fontSize: 20, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ThemedText( - AppLocalizations.of(context).scanTextMessages, - textAlign: TextAlign.center, - opacity: 0.7, - forceDark: true, - ), - ), - const SizedBox(height: 32), - PrimaryButton( - text: AppLocalizations.of(context).startScanning, - icon: Icons.search_rounded, - onPressed: _scanSms, - width: 200, - height: 56, - backgroundColor: AppColors.primaryColor, - ), - ], - ), - ), - ], - ); - } - - // ๊ตฌ๋… ํ‘œ์‹œ ์ƒํƒœ UI - Widget _buildSubscriptionState() { - if (_currentIndex >= _scannedSubscriptions.length) { - // ์ฒ˜๋ฆฌ ์™„๋ฃŒ ํ›„ ์ดˆ๊ธฐ ์ƒํƒœ๋กœ ๋ณต๊ท€ - _scannedSubscriptions = []; - _currentIndex = 0; - return _buildInitialState(); // ์Šค์บ” ๋ฒ„ํŠผ์ด ์žˆ๋Š” ์ดˆ๊ธฐ ํ™”๋ฉด์œผ๋กœ ๋Œ์•„๊ฐ - } - - final subscription = _scannedSubscriptions[_currentIndex]; - final categoryProvider = Provider.of(context, listen: false); - - // ๊ตฌ๋… ๋ฆฌ์ŠคํŠธ ์นด๋“œ๋ฅผ ํ‘œ์‹œํ•  ๋•Œ URL ํ•„๋“œ ์ž๋™ ์„ค์ • - if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) { - _websiteUrlController.text = subscription.websiteUrl!; - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ๊ด‘๊ณ  ์œ„์ ฏ ์ถ”๊ฐ€ - const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ์ง„ํ–‰ ์ƒํƒœ ํ‘œ์‹œ - LinearProgressIndicator( - value: (_currentIndex + 1) / _scannedSubscriptions.length, - backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary), - ), - const SizedBox(height: 8), - ThemedText( - '${_currentIndex + 1}/${_scannedSubscriptions.length}', - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 24), - - // ๊ตฌ๋… ์ •๋ณด ์นด๋“œ - GlassmorphismCard( - width: double.infinity, - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText( - AppLocalizations.of(context).foundSubscription, - fontSize: 18, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 24), - // ์„œ๋น„์Šค๋ช… - ThemedText( - AppLocalizations.of(context).serviceName, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - ThemedText( - subscription.serviceName, - fontSize: 22, - fontWeight: FontWeight.bold, - forceDark: true, - ), - const SizedBox(height: 16), - - // ๊ธˆ์•ก ๋ฐ ๊ฒฐ์ œ ์ฃผ๊ธฐ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText( - AppLocalizations.of(context).monthlyCost, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - // ์–ธ์–ด๋ณ„ ํ†ตํ™” ํ‘œ์‹œ - FutureBuilder( - future: CurrencyUtil.formatAmountWithLocale( - subscription.monthlyCost, - subscription.currency, - context.read().locale.languageCode, - ), - builder: (context, snapshot) { - return ThemedText( - snapshot.data ?? '-', - fontSize: 18, - fontWeight: FontWeight.bold, - forceDark: true, - ); - }, - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText( - AppLocalizations.of(context).billingCycle, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - ThemedText( - subscription.billingCycle, - fontSize: 16, - fontWeight: FontWeight.w500, - forceDark: true, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // ๋‹ค์Œ ๊ฒฐ์ œ์ผ - ThemedText( - AppLocalizations.of(context).nextBillingDateLabel, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - ThemedText( - _getNextBillingText(subscription.nextBillingDate), - fontSize: 16, - fontWeight: FontWeight.w500, - forceDark: true, - ), - const SizedBox(height: 16), - - // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ - ThemedText( - AppLocalizations.of(context).category, - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 8), - CategorySelector( - categories: categoryProvider.categories, - selectedCategoryId: _selectedCategoryId ?? subscription.category, - onChanged: (categoryId) { - setState(() { - _selectedCategoryId = categoryId; - }); - }, - baseColor: (() { - final categoryId = _selectedCategoryId ?? subscription.category; - if (categoryId == null) return null; - final category = categoryProvider.getCategoryById(categoryId); - if (category == null) return null; - return Color(int.parse(category.color.replaceFirst('#', '0xFF'))); - })(), - isGlassmorphism: true, - ), - const SizedBox(height: 24), - - // ์›น์‚ฌ์ดํŠธ URL ์ž…๋ ฅ ํ•„๋“œ ์ถ”๊ฐ€/์ˆ˜์ • - BaseTextField( - controller: _websiteUrlController, - label: AppLocalizations.of(context).websiteUrlAuto, - hintText: AppLocalizations.of(context).websiteUrlHint, - prefixIcon: Icon( - Icons.language, - color: AppColors.navyGray, - ), - style: TextStyle( - color: AppColors.darkNavy, - ), - fillColor: AppColors.pureWhite.withValues(alpha: 0.8), - ), - const SizedBox(height: 32), - - // ์ž‘์—… ๋ฒ„ํŠผ - Row( - children: [ - Expanded( - child: SecondaryButton( - text: AppLocalizations.of(context).skip, - onPressed: _skipCurrentSubscription, - height: 48, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - text: AppLocalizations.of(context).add, - onPressed: _addCurrentSubscription, - height: 48, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ], - ); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_scannedSubscriptions.isNotEmpty && - _currentIndex < _scannedSubscriptions.length) { - final currentSub = _scannedSubscriptions[_currentIndex]; - if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { - _websiteUrlController.text = currentSub.websiteUrl!; - } - } - } -} +} \ No newline at end of file diff --git a/lib/services/sms_scan/subscription_converter.dart b/lib/services/sms_scan/subscription_converter.dart new file mode 100644 index 0000000..bd61b73 --- /dev/null +++ b/lib/services/sms_scan/subscription_converter.dart @@ -0,0 +1,79 @@ +import '../../models/subscription.dart'; +import '../../models/subscription_model.dart'; + +class SubscriptionConverter { + // SubscriptionModel ๋ฆฌ์ŠคํŠธ๋ฅผ Subscription ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜ + List convertModelsToSubscriptions(List models) { + final result = []; + + for (var model in models) { + try { + final subscription = _convertSingle(model); + result.add(subscription); + + print('๋ชจ๋ธ ๋ณ€ํ™˜ ์„ฑ๊ณต: ${model.serviceName}, ์นดํ…Œ๊ณ ๋ฆฌID: ${model.categoryId}, URL: ${model.websiteUrl}, ํ†ตํ™”: ${model.currency}'); + } catch (e) { + print('๋ชจ๋ธ ๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); + } + } + + return result; + } + + // ๋‹จ์ผ ๋ชจ๋ธ ๋ณ€ํ™˜ + Subscription _convertSingle(SubscriptionModel model) { + return Subscription( + id: model.id, + serviceName: model.serviceName, + monthlyCost: model.monthlyCost, + billingCycle: _denormalizeBillingCycle(model.billingCycle), // ์˜์–ด -> ํ•œ๊ตญ์–ด + nextBillingDate: model.nextBillingDate, + category: model.categoryId, // categoryId๋ฅผ category๋กœ ๋งคํ•‘ + repeatCount: model.repeatCount > 0 ? model.repeatCount : 1, + lastPaymentDate: model.lastPaymentDate, + websiteUrl: model.websiteUrl, + currency: model.currency, + ); + } + + // billingCycle ์—ญ์ •๊ทœํ™” (์˜์–ด -> ํ•œ๊ตญ์–ด) + String _denormalizeBillingCycle(String cycle) { + switch (cycle.toLowerCase()) { + case 'monthly': + return '์›”๊ฐ„'; + case 'yearly': + case 'annually': + return '์—ฐ๊ฐ„'; + case 'weekly': + return '์ฃผ๊ฐ„'; + case 'daily': + return '์ผ๊ฐ„'; + case 'quarterly': + return '๋ถ„๊ธฐ๋ณ„'; + case 'semi-annually': + return '๋ฐ˜๊ธฐ๋ณ„'; + default: + return cycle; // ์•Œ ์ˆ˜ ์—†๋Š” ํ˜•์‹์€ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ + } + } + + // billingCycle ์ •๊ทœํ™” (ํ•œ๊ตญ์–ด -> ์˜์–ด) + String normalizeBillingCycle(String cycle) { + switch (cycle) { + case '์›”๊ฐ„': + return 'monthly'; + case '์—ฐ๊ฐ„': + return 'yearly'; + case '์ฃผ๊ฐ„': + return 'weekly'; + case '์ผ๊ฐ„': + return 'daily'; + case '๋ถ„๊ธฐ๋ณ„': + return 'quarterly'; + case '๋ฐ˜๊ธฐ๋ณ„': + return 'semi-annually'; + default: + return 'monthly'; // ๊ธฐ๋ณธ๊ฐ’ + } + } +} \ No newline at end of file diff --git a/lib/services/sms_scan/subscription_filter.dart b/lib/services/sms_scan/subscription_filter.dart new file mode 100644 index 0000000..627f692 --- /dev/null +++ b/lib/services/sms_scan/subscription_filter.dart @@ -0,0 +1,60 @@ +import '../../models/subscription.dart'; +import '../../models/subscription_model.dart'; + +class SubscriptionFilter { + // ์ค‘๋ณต ๊ตฌ๋… ํ•„ํ„ฐ๋ง (์„œ๋น„์Šค๋ช…๊ณผ ๊ธˆ์•ก์ด ๊ฐ™์œผ๋ฉด ์ค‘๋ณต์œผ๋กœ ๊ฐ„์ฃผ) + List filterDuplicates( + List scanned, List existing) { + print('_filterDuplicates: ์Šค์บ”๋œ ๊ตฌ๋… ${scanned.length}๊ฐœ, ๊ธฐ์กด ๊ตฌ๋… ${existing.length}๊ฐœ'); + + // ์ค‘๋ณต๋˜์ง€ ์•Š์€ ๊ตฌ๋…๋งŒ ํ•„ํ„ฐ๋ง + return scanned.where((scannedSub) { + // ๊ธฐ์กด ๊ตฌ๋… ์ค‘์— ๊ฐ™์€ ์„œ๋น„์Šค๋ช…๊ณผ ์›” ๋น„์šฉ์„ ๊ฐ€์ง„ ๊ฒƒ์ด ์žˆ๋Š”์ง€ ํ™•์ธ + final isDuplicate = existing.any((existingSub) { + final isSameName = existingSub.serviceName.toLowerCase() == + scannedSub.serviceName.toLowerCase(); + final isSameCost = existingSub.monthlyCost == scannedSub.monthlyCost; + + if (isSameName && isSameCost) { + print('์ค‘๋ณต ๋ฐœ๊ฒฌ: ${scannedSub.serviceName} (${scannedSub.monthlyCost}์›)'); + return true; + } + return false; + }); + + return !isDuplicate; + }).toList(); + } + + // ๋ฐ˜๋ณต ํšŸ์ˆ˜ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง + List filterByRepeatCount(List subscriptions, int minCount) { + return subscriptions.where((sub) => sub.repeatCount >= minCount).toList(); + } + + // ๋‚ ์งœ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง (์„ ํƒ์ ) + List filterByDateRange( + List subscriptions, DateTime startDate, DateTime endDate) { + return subscriptions.where((sub) { + return sub.nextBillingDate.isAfter(startDate) && + sub.nextBillingDate.isBefore(endDate); + }).toList(); + } + + // ๊ธˆ์•ก ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง (์„ ํƒ์ ) + List filterByPriceRange( + List subscriptions, double minPrice, double maxPrice) { + return subscriptions + .where((sub) => sub.monthlyCost >= minPrice && sub.monthlyCost <= maxPrice) + .toList(); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ๊ธฐ๋ฐ˜ ํ•„ํ„ฐ๋ง (์„ ํƒ์ ) + List filterByCategories( + List subscriptions, List categoryIds) { + if (categoryIds.isEmpty) return subscriptions; + + return subscriptions.where((sub) { + return sub.category != null && categoryIds.contains(sub.category); + }).toList(); + } +} \ No newline at end of file diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 99bdd65..9f5d854 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -1,385 +1,79 @@ -import 'dart:convert'; -import 'package:flutter/services.dart'; -import 'package:flutter/material.dart'; -import 'url_matcher/models/service_info.dart'; -import 'url_matcher/data/legacy_service_data.dart'; - // ServiceInfo๋ฅผ ์™ธ๋ถ€์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๋„๋ก export export 'url_matcher/models/service_info.dart'; -/// ๊ตฌ๋… ์„œ๋น„์Šค์™€ ์›น์‚ฌ์ดํŠธ URL ๋งค์นญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +import 'url_matcher/models/service_info.dart'; +import 'url_matcher/data/service_data_repository.dart'; +import 'url_matcher/services/url_matcher_service.dart'; +import 'url_matcher/services/category_mapper_service.dart'; +import 'url_matcher/services/cancellation_url_service.dart'; +import 'url_matcher/services/service_name_resolver.dart'; +import 'url_matcher/services/sms_extractor_service.dart'; + +/// ๊ตฌ๋… ์„œ๋น„์Šค์™€ ์›น์‚ฌ์ดํŠธ URL ๋งค์นญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค (Facade ํŒจํ„ด) class SubscriptionUrlMatcher { - static Map? _servicesData; - static bool _isInitialized = false; + static ServiceDataRepository? _dataRepository; + static UrlMatcherService? _urlMatcher; + static CategoryMapperService? _categoryMapper; + static CancellationUrlService? _cancellationService; + static ServiceNameResolver? _nameResolver; + static SmsExtractorService? _smsExtractor; - /// JSON ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” + /// ์„œ๋น„์Šค ์ดˆ๊ธฐํ™” static Future initialize() async { - if (_isInitialized) return; + if (_dataRepository != null && _dataRepository!.isInitialized) return; - try { - final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); - _servicesData = json.decode(jsonString); - _isInitialized = true; - print('SubscriptionUrlMatcher: JSON ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์™„๋ฃŒ'); - } catch (e) { - print('SubscriptionUrlMatcher: JSON ๋กœ๋“œ ์‹คํŒจ - $e'); - // ๋กœ๋“œ ์‹คํŒจ์‹œ ๊ธฐ์กด ํ•˜๋“œ์ฝ”๋”ฉ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ - _isInitialized = true; - } + // 1. ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™” + _dataRepository = ServiceDataRepository(); + await _dataRepository!.initialize(); + + // 2. ์„œ๋น„์Šค ์ดˆ๊ธฐํ™” + _categoryMapper = CategoryMapperService(_dataRepository!); + _urlMatcher = UrlMatcherService(_dataRepository!, _categoryMapper!); + _cancellationService = CancellationUrlService(_dataRepository!, _urlMatcher!); + _nameResolver = ServiceNameResolver(_dataRepository!); + _smsExtractor = SmsExtractorService(_urlMatcher!, _categoryMapper!); } /// ๋„๋ฉ”์ธ ์ถ”์ถœ (www์™€ TLD ์ œ์™ธ) static String? extractDomain(String url) { - try { - final uri = Uri.parse(url); - final host = uri.host.toLowerCase(); - - // ๋„๋ฉ”์ธ ๋ถ€๋ถ„ ์ถ”์ถœ - var parts = host.split('.'); - - // www ์ œ๊ฑฐ - if (parts.isNotEmpty && parts[0] == 'www') { - parts = parts.sublist(1); - } - - // ์„œ๋ธŒ๋„๋ฉ”์ธ ์ฒ˜๋ฆฌ (์˜ˆ: music.youtube.com) - if (parts.length >= 3) { - // ์„œ๋ธŒ๋„๋ฉ”์ธ ํฌํ•จ ์ „์ฒด ๋„๋ฉ”์ธ ๋ฐ˜ํ™˜ - return parts.sublist(0, parts.length - 1).join('.'); - } else if (parts.length >= 2) { - // ๋ฉ”์ธ ๋„๋ฉ”์ธ๋งŒ ๋ฐ˜ํ™˜ - return parts[0]; - } - - return null; - } catch (e) { - print('SubscriptionUrlMatcher: ๋„๋ฉ”์ธ ์ถ”์ถœ ์‹คํŒจ - $e'); - return null; - } + return _urlMatcher?.extractDomain(url); } /// URL๋กœ ์„œ๋น„์Šค ์ฐพ๊ธฐ static Future findServiceByUrl(String url) async { await initialize(); - - final domain = extractDomain(url); - if (domain == null) return null; - - // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - for (final categoryEntry in categories.entries) { - final categoryId = categoryEntry.key; - final categoryData = categoryEntry.value as Map; - final services = categoryData['services'] as Map; - - for (final serviceEntry in services.entries) { - final serviceId = serviceEntry.key; - final serviceData = serviceEntry.value as Map; - final domains = List.from(serviceData['domains'] ?? []); - - // ๋„๋ฉ”์ธ์ด ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ - for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { - final names = List.from(serviceData['names'] ?? []); - final urls = serviceData['urls'] as Map?; - - return ServiceInfo( - serviceId: serviceId, - serviceName: names.isNotEmpty ? names[0] : serviceId, - serviceUrl: urls?['kr'] ?? urls?['en'], - cancellationUrl: null, - categoryId: _getCategoryIdByKey(categoryId), - categoryNameKr: categoryData['nameKr'] ?? '', - categoryNameEn: categoryData['nameEn'] ?? '', - ); - } - } - } - } - } - - // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์ฐพ๊ธฐ - for (final entry in LegacyServiceData.allServices.entries) { - final serviceUrl = entry.value; - final serviceDomain = extractDomain(serviceUrl); - - if (serviceDomain != null && - (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { - return ServiceInfo( - serviceId: entry.key, - serviceName: entry.key, - serviceUrl: serviceUrl, - cancellationUrl: null, - categoryId: _getCategoryForLegacyService(entry.key), - categoryNameKr: '', - categoryNameEn: '', - ); - } - } - - return null; + return _urlMatcher?.findServiceByUrl(url); } /// ์„œ๋น„์Šค๋ช…์œผ๋กœ URL ์ฐพ๊ธฐ (๊ธฐ์กด suggestUrl ๋ฉ”์„œ๋“œ ์œ ์ง€) static String? suggestUrl(String serviceName) { - if (serviceName.isEmpty) { - print('SubscriptionUrlMatcher: ๋นˆ serviceName'); - return null; - } - - // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋น„๊ต - final lowerName = serviceName.toLowerCase().trim(); - - try { - // ์ •ํ™•ํ•œ ๋งค์นญ์„ ๋จผ์ € ์‹œ๋„ - for (final entry in LegacyServiceData.allServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print('SubscriptionUrlMatcher: ์ •ํ™•ํ•œ ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // OTT ์„œ๋น„์Šค ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.ottServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: OTT ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // ์Œ์•… ์„œ๋น„์Šค ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.musicServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: ์Œ์•… ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // AI ์„œ๋น„์Šค ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.aiServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: AI ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // ๊ฐœ๋ฐœ ์„œ๋น„์Šค ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.programmingServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: ๊ฐœ๋ฐœ ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // ์˜คํ”ผ์Šค ํˆด ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.officeTools.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: ์˜คํ”ผ์Šค ํˆด ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // ๊ธฐํƒ€ ์„œ๋น„์Šค ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.otherServices.entries) { - if (lowerName.contains(entry.key.toLowerCase())) { - print( - 'SubscriptionUrlMatcher: ๊ธฐํƒ€ ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); - return entry.value; - } - } - - // ์œ ์‚ฌํ•œ ์ด๋ฆ„ ๊ฒ€์‚ฌ (ํผ์ง€ ๋งค์นญ) - ๋‹จ์–ด ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฒ€์ƒ‰ - for (final entry in LegacyServiceData.allServices.entries) { - final serviceWords = lowerName.split(' '); - final keyWords = entry.key.toLowerCase().split(' '); - - // ๋‹จ์–ด ๋‹จ์œ„๋กœ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ - for (final word in serviceWords) { - if (word.length > 2 && - keyWords.any((keyWord) => keyWord.contains(word))) { - print( - 'SubscriptionUrlMatcher: ๋‹จ์–ด ๊ธฐ๋ฐ˜ ๋งค์นญ - $word (in $lowerName) -> ${entry.key}'); - return entry.value; - } - } - } - - // ์ถ”์ถœ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ์ด ์žˆ๋Š”์ง€ ํ™•์ธ - final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName); - if (domainMatch != null && domainMatch.group(1)!.length > 2) { - final domain = domainMatch.group(1)!.trim(); - if (domain.length > 2 && - !['the', 'and', 'for', 'www'].contains(domain)) { - final url = 'https://www.$domain.com'; - print('SubscriptionUrlMatcher: ๋„๋ฉ”์ธ ์ถ”์ถœ - $lowerName -> $url'); - return url; - } - } - - print('SubscriptionUrlMatcher: ๋งค์นญ ์‹คํŒจ - $lowerName'); - return null; - } catch (e) { - print('SubscriptionUrlMatcher: URL ๋งค์นญ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e'); - return null; - } + return _urlMatcher?.suggestUrl(serviceName); } - /// ํ•ด์ง€ ์•ˆ๋‚ด URL ์ฐพ๊ธฐ (๊ฐœ์„ ๋œ ๋ฒ„์ „) + /// ์„œ๋น„์Šค๋ช… ๋˜๋Š” URL๋กœ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ static Future findCancellationUrl({ String? serviceName, String? websiteUrl, String locale = 'kr', }) async { await initialize(); - - // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - // 1. ์„œ๋น„์Šค๋ช…์œผ๋กœ ์ฐพ๊ธฐ - if (serviceName != null && serviceName.isNotEmpty) { - final lowerName = serviceName.toLowerCase().trim(); - - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - - for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; - if (cancellationUrls != null) { - // ์š”์ฒญํ•œ ์–ธ์–ด์˜ URL์ด ์žˆ์œผ๋ฉด ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ๋‹ค๋ฅธ ์–ธ์–ด URL ๋ฐ˜ํ™˜ - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; - } - } - } - } - } - } - - // 2. URL๋กœ ์ฐพ๊ธฐ - if (websiteUrl != null && websiteUrl.isNotEmpty) { - final domain = extractDomain(websiteUrl); - if (domain != null) { - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final domains = List.from((serviceData as Map)['domains'] ?? []); - - for (final serviceDomain in domains) { - if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { - final cancellationUrls = serviceData['cancellationUrls'] as Map?; - if (cancellationUrls != null) { - return cancellationUrls[locale] ?? - cancellationUrls[locale == 'kr' ? 'en' : 'kr']; - } - } - } - } - } - } - } - } - - // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์ฐพ๊ธฐ - return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); + return _cancellationService?.findCancellationUrl( + serviceName: serviceName, + websiteUrl: websiteUrl, + locale: locale, + ); } - - /// ์„œ๋น„์Šค๋ช… ๋˜๋Š” ์›น์‚ฌ์ดํŠธ URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ (๋ ˆ๊ฑฐ์‹œ) - static String? _findCancellationUrlLegacy(String serviceNameOrUrl) { - if (serviceNameOrUrl.isEmpty) { - return null; - } - - // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ฒ˜๋ฆฌ - final String lowerText = serviceNameOrUrl.toLowerCase().trim(); - - // ์ง์ ‘ ์„œ๋น„์Šค๋ช…์œผ๋กœ ์ฐพ๊ธฐ - if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) { - return LegacyServiceData.cancellationUrls[lowerText]; - } - - // ์„œ๋น„์Šค๋ช…์— ๋ถ€๋ถ„ ํฌํ•จ์œผ๋กœ ์ฐพ๊ธฐ - for (var entry in LegacyServiceData.cancellationUrls.entries) { - final String key = entry.key.toLowerCase(); - if (lowerText.contains(key) || key.contains(lowerText)) { - return entry.value; - } - } - - // URL์„ ํ†ตํ•ด ์„œ๋น„์Šค๋ช… ์ถ”์ถœ ํ›„ ์ฐพ๊ธฐ - if (lowerText.startsWith('http')) { - // URL ๋„๋ฉ”์ธ ์ถ”์ถœ (https://www.netflix.com ์—์„œ netflix ์ถ”์ถœ) - final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)'); - final match = domainRegex.firstMatch(lowerText); - - if (match != null && match.groupCount >= 1) { - final domain = match.group(1)?.toLowerCase() ?? ''; - - // ๋„๋ฉ”์ธ์œผ๋กœ ์„œ๋น„์Šค๋ช… ์ฐพ๊ธฐ - for (var entry in LegacyServiceData.cancellationUrls.entries) { - if (entry.key.toLowerCase().contains(domain)) { - return entry.value; - } - } - } - } - - // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ - return null; - } - + /// ์„œ๋น„์Šค์— ๊ณต์‹ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ static Future hasCancellationPage(String serviceNameOrUrl) async { - // ์ƒˆ๋กœ์šด JSON ๊ธฐ๋ฐ˜ ๋ฐฉ์‹์œผ๋กœ ํ™•์ธ - final cancellationUrl = await findCancellationUrl( - serviceName: serviceNameOrUrl, - websiteUrl: serviceNameOrUrl, - ); - return cancellationUrl != null; + await initialize(); + return await _cancellationService?.hasCancellationPage(serviceNameOrUrl) ?? false; } /// ์„œ๋น„์Šค๋ช…์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ static Future findCategoryByServiceName(String serviceName) async { await initialize(); - if (serviceName.isEmpty) return null; - - final lowerName = serviceName.toLowerCase().trim(); - - // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ - if (_servicesData != null) { - final categories = _servicesData!['categories'] as Map; - - for (final categoryEntry in categories.entries) { - final categoryId = categoryEntry.key; - final categoryData = categoryEntry.value as Map; - final services = categoryData['services'] as Map; - - for (final serviceData in services.values) { - final names = List.from((serviceData as Map)['names'] ?? []); - - for (final name in names) { - if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { - return _getCategoryIdByKey(categoryId); - } - } - } - } - } - - // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ถ”์ธก - return _getCategoryForLegacyService(serviceName); + return _categoryMapper?.findCategoryByServiceName(serviceName); } /// ํ˜„์žฌ ๋กœ์ผ€์ผ์— ๋”ฐ๋ผ ์„œ๋น„์Šค ํ‘œ์‹œ๋ช… ๊ฐ€์ ธ์˜ค๊ธฐ @@ -388,189 +82,26 @@ class SubscriptionUrlMatcher { required String locale, }) async { await initialize(); - - if (_servicesData == null) { - return serviceName; - } - - final lowerName = serviceName.toLowerCase().trim(); - final categories = _servicesData!['categories'] as Map; - - // JSON์—์„œ ์„œ๋น„์Šค ์ฐพ๊ธฐ - for (final categoryData in categories.values) { - final services = (categoryData as Map)['services'] as Map; - - for (final serviceData in services.values) { - final data = serviceData as Map; - final names = List.from(data['names'] ?? []); - - // names ๋ฐฐ์—ด์— ์žˆ๋Š”์ง€ ํ™•์ธ - for (final name in names) { - if (lowerName == name.toLowerCase() || - lowerName.contains(name.toLowerCase()) || - name.toLowerCase().contains(lowerName)) { - // ๋กœ์ผ€์ผ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ์ด๋ฆ„ ๋ฐ˜ํ™˜ - if (locale == 'ko' || locale == 'kr') { - return data['nameKr'] ?? serviceName; - } else { - return data['nameEn'] ?? serviceName; - } - } - } - - // nameKr/nameEn์— ์ง์ ‘ ๋งค์นญ ํ™•์ธ - final nameKr = (data['nameKr'] ?? '').toString().toLowerCase(); - final nameEn = (data['nameEn'] ?? '').toString().toLowerCase(); - - if (lowerName == nameKr || lowerName == nameEn) { - if (locale == 'ko' || locale == 'kr') { - return data['nameKr'] ?? serviceName; - } else { - return data['nameEn'] ?? serviceName; - } - } - } - } - - // ์ฐพ์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ ์›๋ž˜ ์ด๋ฆ„ ๋ฐ˜ํ™˜ - return serviceName; - } - - /// ์นดํ…Œ๊ณ ๋ฆฌ ํ‚ค๋ฅผ ์‹ค์ œ ์นดํ…Œ๊ณ ๋ฆฌ ID๋กœ ๋งคํ•‘ - static String _getCategoryIdByKey(String key) { - // ์—ฌ๊ธฐ์— ์‹ค์ œ ์•ฑ์˜ ์นดํ…Œ๊ณ ๋ฆฌ ID ๋งคํ•‘์„ ์ถ”๊ฐ€ - // ์ž„์‹œ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ๋ช… ๊ธฐ๋ฐ˜ ๋งคํ•‘ - switch (key) { - case 'music': - return 'music_streaming'; - case 'ott': - return 'ott_services'; - case 'storage': - return 'cloud_storage'; - case 'ai': - return 'ai_services'; - case 'programming': - return 'dev_tools'; - case 'office': - return 'office_tools'; - case 'lifestyle': - return 'lifestyle'; - case 'shopping': - return 'shopping'; - case 'gaming': - return 'gaming'; - case 'telecom': - return 'telecom'; - default: - return 'other'; - } - } - - /// ๋ ˆ๊ฑฐ์‹œ ์„œ๋น„์Šค๋ช…์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ถ”์ธก - static String _getCategoryForLegacyService(String serviceName) { - final lowerName = serviceName.toLowerCase(); - - if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; - if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; - if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; - if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; - if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; - if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; - if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; - if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; - if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; - - return 'other'; + return await _nameResolver?.getServiceDisplayName( + serviceName: serviceName, + locale: locale, + ) ?? serviceName; } /// SMS์—์„œ URL๊ณผ ์„œ๋น„์Šค ์ •๋ณด ์ถ”์ถœ static Future extractServiceFromSms(String smsText) async { await initialize(); - - // URL ํŒจํ„ด ์ฐพ๊ธฐ - final urlPattern = RegExp( - r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', - caseSensitive: false, - ); - - final matches = urlPattern.allMatches(smsText); - - for (final match in matches) { - final url = match.group(0); - if (url != null) { - final serviceInfo = await findServiceByUrl(url); - if (serviceInfo != null) { - return serviceInfo; - } - } - } - - // URL๋กœ ๋ชป ์ฐพ์•˜์œผ๋ฉด ์„œ๋น„์Šค๋ช…์œผ๋กœ ์‹œ๋„ - final lowerSms = smsText.toLowerCase(); - - // ๋ชจ๋“  ์„œ๋น„์Šค๋ช… ๊ฒ€์‚ฌ - for (final entry in LegacyServiceData.allServices.entries) { - if (lowerSms.contains(entry.key.toLowerCase())) { - final categoryId = await findCategoryByServiceName(entry.key) ?? 'other'; - - return ServiceInfo( - serviceId: entry.key, - serviceName: entry.key, - serviceUrl: entry.value, - cancellationUrl: null, - categoryId: categoryId, - categoryNameKr: '', - categoryNameEn: '', - ); - } - } - - return null; + return _smsExtractor?.extractServiceFromSms(smsText); } /// URL์ด ์•Œ๋ ค์ง„ ์„œ๋น„์Šค URL์ธ์ง€ ํ™•์ธ static Future isKnownServiceUrl(String url) async { - final serviceInfo = await findServiceByUrl(url); - return serviceInfo != null; + await initialize(); + return await _urlMatcher?.isKnownServiceUrl(url) ?? false; } /// ์ž…๋ ฅ๋œ ์„œ๋น„์Šค ์ด๋ฆ„์ด๋‚˜ ๋ฌธ์ž์—ด์—์„œ ๋งค์นญ๋˜๋Š” URL์„ ์ฐพ์•„ ๋ฐ˜ํ™˜ (๋ ˆ๊ฑฐ์‹œ ํ˜ธํ™˜์„ฑ) static String? findMatchingUrl(String text, {bool usePartialMatch = true}) { - // ์ž…๋ ฅ ํ…์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ๊ฑฐ๋‚˜ null์ธ ๊ฒฝ์šฐ - if (text.isEmpty) { - return null; - } - - // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ฒ˜๋ฆฌ - final String lowerText = text.toLowerCase().trim(); - - // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ - if (LegacyServiceData.allServices.containsKey(lowerText)) { - return LegacyServiceData.allServices[lowerText]; - } - - // ๋ถ€๋ถ„ ์ผ์น˜ ๊ฒ€์ƒ‰์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ - if (usePartialMatch) { - // ๊ฐ€์žฅ ๊ธด ๋ถ€๋ถ„ ๋งค์นญ ์ฐพ๊ธฐ - String? bestMatch; - int maxLength = 0; - - for (var entry in LegacyServiceData.allServices.entries) { - final String key = entry.key; - - // ์ž…๋ ฅ๋œ ํ…์ŠคํŠธ์— ์„œ๋น„์Šค ํ‚ค์›Œ๋“œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๊ฑฐ๋‚˜, ์„œ๋น„์Šค ํ‚ค์›Œ๋“œ์— ์ž…๋ ฅ๋œ ํ…์ŠคํŠธ๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ - if (lowerText.contains(key) || key.contains(lowerText)) { - // ๋” ๊ธด ๋งค์นญ์„ ์šฐ์„ ์‹œ - if (key.length > maxLength) { - maxLength = key.length; - bestMatch = entry.value; - } - } - } - - return bestMatch; - } - - return null; + return _urlMatcher?.findMatchingUrl(text, usePartialMatch: usePartialMatch); } } \ No newline at end of file diff --git a/lib/services/url_matcher/data/service_data_repository.dart b/lib/services/url_matcher/data/service_data_repository.dart new file mode 100644 index 0000000..018f66c --- /dev/null +++ b/lib/services/url_matcher/data/service_data_repository.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; + +/// ์„œ๋น„์Šค ๋ฐ์ดํ„ฐ๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์ €์žฅ์†Œ ํด๋ž˜์Šค +class ServiceDataRepository { + Map? _servicesData; + bool _isInitialized = false; + + /// JSON ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” + Future initialize() async { + if (_isInitialized) return; + + try { + final jsonString = await rootBundle.loadString('assets/data/subscription_services.json'); + _servicesData = json.decode(jsonString); + _isInitialized = true; + print('ServiceDataRepository: JSON ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์™„๋ฃŒ'); + } catch (e) { + print('ServiceDataRepository: JSON ๋กœ๋“œ ์‹คํŒจ - $e'); + // ๋กœ๋“œ ์‹คํŒจ์‹œ ๊ธฐ์กด ํ•˜๋“œ์ฝ”๋”ฉ ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ + _isInitialized = true; + } + } + + /// ์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + Map? getServicesData() => _servicesData; + + /// ์ดˆ๊ธฐํ™” ์—ฌ๋ถ€ ํ™•์ธ + bool get isInitialized => _isInitialized; +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/cancellation_url_service.dart b/lib/services/url_matcher/services/cancellation_url_service.dart new file mode 100644 index 0000000..74542ac --- /dev/null +++ b/lib/services/url_matcher/services/cancellation_url_service.dart @@ -0,0 +1,129 @@ +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; +import 'url_matcher_service.dart'; + +/// ํ•ด์ง€ URL ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +class CancellationUrlService { + final ServiceDataRepository _dataRepository; + final UrlMatcherService _urlMatcher; + + CancellationUrlService(this._dataRepository, this._urlMatcher); + + /// ์„œ๋น„์Šค๋ช… ๋˜๋Š” URL๋กœ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ + Future findCancellationUrl({ + String? serviceName, + String? websiteUrl, + String locale = 'kr', + }) async { + // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + // 1. ์„œ๋น„์Šค๋ช…์œผ๋กœ ์ฐพ๊ธฐ + if (serviceName != null && serviceName.isNotEmpty) { + final lowerName = serviceName.toLowerCase().trim(); + + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + // ์š”์ฒญํ•œ ์–ธ์–ด์˜ URL์ด ์žˆ์œผ๋ฉด ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ๋‹ค๋ฅธ ์–ธ์–ด URL ๋ฐ˜ํ™˜ + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + + // 2. URL๋กœ ์ฐพ๊ธฐ + if (websiteUrl != null && websiteUrl.isNotEmpty) { + final domain = _urlMatcher.extractDomain(websiteUrl); + if (domain != null) { + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final domains = List.from((serviceData as Map)['domains'] ?? []); + + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final cancellationUrls = serviceData['cancellationUrls'] as Map?; + if (cancellationUrls != null) { + return cancellationUrls[locale] ?? + cancellationUrls[locale == 'kr' ? 'en' : 'kr']; + } + } + } + } + } + } + } + } + + // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์ฐพ๊ธฐ + return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? ''); + } + + /// ์„œ๋น„์Šค๋ช… ๋˜๋Š” ์›น์‚ฌ์ดํŠธ URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ (๋ ˆ๊ฑฐ์‹œ) + String? _findCancellationUrlLegacy(String serviceNameOrUrl) { + if (serviceNameOrUrl.isEmpty) { + return null; + } + + // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ฒ˜๋ฆฌ + final String lowerText = serviceNameOrUrl.toLowerCase().trim(); + + // ์ง์ ‘ ์„œ๋น„์Šค๋ช…์œผ๋กœ ์ฐพ๊ธฐ + if (LegacyServiceData.cancellationUrls.containsKey(lowerText)) { + return LegacyServiceData.cancellationUrls[lowerText]; + } + + // ์„œ๋น„์Šค๋ช…์— ๋ถ€๋ถ„ ํฌํ•จ์œผ๋กœ ์ฐพ๊ธฐ + for (var entry in LegacyServiceData.cancellationUrls.entries) { + final String key = entry.key.toLowerCase(); + if (lowerText.contains(key) || key.contains(lowerText)) { + return entry.value; + } + } + + // URL์„ ํ†ตํ•ด ์„œ๋น„์Šค๋ช… ์ถ”์ถœ ํ›„ ์ฐพ๊ธฐ + if (lowerText.startsWith('http')) { + // URL ๋„๋ฉ”์ธ ์ถ”์ถœ (https://www.netflix.com ์—์„œ netflix ์ถ”์ถœ) + final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)'); + final match = domainRegex.firstMatch(lowerText); + + if (match != null && match.groupCount >= 1) { + final domain = match.group(1)?.toLowerCase() ?? ''; + + // ๋„๋ฉ”์ธ์œผ๋กœ ์„œ๋น„์Šค๋ช… ์ฐพ๊ธฐ + for (var entry in LegacyServiceData.cancellationUrls.entries) { + if (entry.key.toLowerCase().contains(domain)) { + return entry.value; + } + } + } + } + + // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๋ฅผ ์ฐพ์ง€ ๋ชปํ•จ + return null; + } + + /// ์„œ๋น„์Šค์— ๊ณต์‹ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + Future hasCancellationPage(String serviceNameOrUrl) async { + // ์ƒˆ๋กœ์šด JSON ๊ธฐ๋ฐ˜ ๋ฐฉ์‹์œผ๋กœ ํ™•์ธ + final cancellationUrl = await findCancellationUrl( + serviceName: serviceNameOrUrl, + websiteUrl: serviceNameOrUrl, + ); + return cancellationUrl != null; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/category_mapper_service.dart b/lib/services/url_matcher/services/category_mapper_service.dart new file mode 100644 index 0000000..872a093 --- /dev/null +++ b/lib/services/url_matcher/services/category_mapper_service.dart @@ -0,0 +1,88 @@ +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; + +/// ์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘ ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +class CategoryMapperService { + final ServiceDataRepository _dataRepository; + + CategoryMapperService(this._dataRepository); + + /// ์„œ๋น„์Šค๋ช…์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + Future findCategoryByServiceName(String serviceName) async { + if (serviceName.isEmpty) return null; + + final lowerName = serviceName.toLowerCase().trim(); + + // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceData in services.values) { + final names = List.from((serviceData as Map)['names'] ?? []); + + for (final name in names) { + if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) { + return getCategoryIdByKey(categoryId); + } + } + } + } + } + + // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ถ”์ธก + return getCategoryForLegacyService(serviceName); + } + + /// ์นดํ…Œ๊ณ ๋ฆฌ ํ‚ค๋ฅผ ์‹ค์ œ ์นดํ…Œ๊ณ ๋ฆฌ ID๋กœ ๋งคํ•‘ + String getCategoryIdByKey(String key) { + // ์—ฌ๊ธฐ์— ์‹ค์ œ ์•ฑ์˜ ์นดํ…Œ๊ณ ๋ฆฌ ID ๋งคํ•‘์„ ์ถ”๊ฐ€ + // ์ž„์‹œ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ๋ช… ๊ธฐ๋ฐ˜ ๋งคํ•‘ + switch (key) { + case 'music': + return 'music_streaming'; + case 'ott': + return 'ott_services'; + case 'storage': + return 'cloud_storage'; + case 'ai': + return 'ai_services'; + case 'programming': + return 'dev_tools'; + case 'office': + return 'office_tools'; + case 'lifestyle': + return 'lifestyle'; + case 'shopping': + return 'shopping'; + case 'gaming': + return 'gaming'; + case 'telecom': + return 'telecom'; + default: + return 'other'; + } + } + + /// ๋ ˆ๊ฑฐ์‹œ ์„œ๋น„์Šค๋ช…์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ถ”์ธก + String getCategoryForLegacyService(String serviceName) { + final lowerName = serviceName.toLowerCase(); + + if (LegacyServiceData.ottServices.containsKey(lowerName)) return 'ott_services'; + if (LegacyServiceData.musicServices.containsKey(lowerName)) return 'music_streaming'; + if (LegacyServiceData.storageServices.containsKey(lowerName)) return 'cloud_storage'; + if (LegacyServiceData.aiServices.containsKey(lowerName)) return 'ai_services'; + if (LegacyServiceData.programmingServices.containsKey(lowerName)) return 'dev_tools'; + if (LegacyServiceData.officeTools.containsKey(lowerName)) return 'office_tools'; + if (LegacyServiceData.lifestyleServices.containsKey(lowerName)) return 'lifestyle'; + if (LegacyServiceData.shoppingServices.containsKey(lowerName)) return 'shopping'; + if (LegacyServiceData.telecomServices.containsKey(lowerName)) return 'telecom'; + + return 'other'; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/service_name_resolver.dart b/lib/services/url_matcher/services/service_name_resolver.dart new file mode 100644 index 0000000..76eaa17 --- /dev/null +++ b/lib/services/url_matcher/services/service_name_resolver.dart @@ -0,0 +1,61 @@ +import '../data/service_data_repository.dart'; + +/// ์„œ๋น„์Šค๋ช… ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +class ServiceNameResolver { + final ServiceDataRepository _dataRepository; + + ServiceNameResolver(this._dataRepository); + + /// ํ˜„์žฌ ๋กœ์ผ€์ผ์— ๋”ฐ๋ผ ์„œ๋น„์Šค ํ‘œ์‹œ๋ช… ๊ฐ€์ ธ์˜ค๊ธฐ + Future getServiceDisplayName({ + required String serviceName, + required String locale, + }) async { + final servicesData = _dataRepository.getServicesData(); + if (servicesData == null) { + return serviceName; + } + + final lowerName = serviceName.toLowerCase().trim(); + final categories = servicesData['categories'] as Map; + + // JSON์—์„œ ์„œ๋น„์Šค ์ฐพ๊ธฐ + for (final categoryData in categories.values) { + final services = (categoryData as Map)['services'] as Map; + + for (final serviceData in services.values) { + final data = serviceData as Map; + final names = List.from(data['names'] ?? []); + + // names ๋ฐฐ์—ด์— ์žˆ๋Š”์ง€ ํ™•์ธ + for (final name in names) { + if (lowerName == name.toLowerCase() || + lowerName.contains(name.toLowerCase()) || + name.toLowerCase().contains(lowerName)) { + // ๋กœ์ผ€์ผ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ์ด๋ฆ„ ๋ฐ˜ํ™˜ + if (locale == 'ko' || locale == 'kr') { + return data['nameKr'] ?? serviceName; + } else { + return data['nameEn'] ?? serviceName; + } + } + } + + // nameKr/nameEn์— ์ง์ ‘ ๋งค์นญ ํ™•์ธ + final nameKr = (data['nameKr'] ?? '').toString().toLowerCase(); + final nameEn = (data['nameEn'] ?? '').toString().toLowerCase(); + + if (lowerName == nameKr || lowerName == nameEn) { + if (locale == 'ko' || locale == 'kr') { + return data['nameKr'] ?? serviceName; + } else { + return data['nameEn'] ?? serviceName; + } + } + } + } + + // ์ฐพ์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ ์›๋ž˜ ์ด๋ฆ„ ๋ฐ˜ํ™˜ + return serviceName; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/sms_extractor_service.dart b/lib/services/url_matcher/services/sms_extractor_service.dart new file mode 100644 index 0000000..4e1f7b5 --- /dev/null +++ b/lib/services/url_matcher/services/sms_extractor_service.dart @@ -0,0 +1,55 @@ +import '../models/service_info.dart'; +import '../data/legacy_service_data.dart'; +import 'url_matcher_service.dart'; +import 'category_mapper_service.dart'; + +/// SMS์—์„œ ์„œ๋น„์Šค ์ •๋ณด๋ฅผ ์ถ”์ถœํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +class SmsExtractorService { + final UrlMatcherService _urlMatcher; + final CategoryMapperService _categoryMapper; + + SmsExtractorService(this._urlMatcher, this._categoryMapper); + + /// SMS์—์„œ URL๊ณผ ์„œ๋น„์Šค ์ •๋ณด ์ถ”์ถœ + Future extractServiceFromSms(String smsText) async { + // URL ํŒจํ„ด ์ฐพ๊ธฐ + final urlPattern = RegExp( + r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)', + caseSensitive: false, + ); + + final matches = urlPattern.allMatches(smsText); + + for (final match in matches) { + final url = match.group(0); + if (url != null) { + final serviceInfo = await _urlMatcher.findServiceByUrl(url); + if (serviceInfo != null) { + return serviceInfo; + } + } + } + + // URL๋กœ ๋ชป ์ฐพ์•˜์œผ๋ฉด ์„œ๋น„์Šค๋ช…์œผ๋กœ ์‹œ๋„ + final lowerSms = smsText.toLowerCase(); + + // ๋ชจ๋“  ์„œ๋น„์Šค๋ช… ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.allServices.entries) { + if (lowerSms.contains(entry.key.toLowerCase())) { + final categoryId = await _categoryMapper.findCategoryByServiceName(entry.key) ?? 'other'; + + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: entry.value, + cancellationUrl: null, + categoryId: categoryId, + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + + return null; + } +} \ No newline at end of file diff --git a/lib/services/url_matcher/services/url_matcher_service.dart b/lib/services/url_matcher/services/url_matcher_service.dart new file mode 100644 index 0000000..090a83d --- /dev/null +++ b/lib/services/url_matcher/services/url_matcher_service.dart @@ -0,0 +1,235 @@ +import '../models/service_info.dart'; +import '../data/service_data_repository.dart'; +import '../data/legacy_service_data.dart'; +import 'category_mapper_service.dart'; + +/// URL ๋งค์นญ ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค +class UrlMatcherService { + final ServiceDataRepository _dataRepository; + final CategoryMapperService _categoryMapper; + + UrlMatcherService(this._dataRepository, this._categoryMapper); + + /// ๋„๋ฉ”์ธ ์ถ”์ถœ (www์™€ TLD ์ œ์™ธ) + String? extractDomain(String url) { + try { + final uri = Uri.parse(url); + final host = uri.host.toLowerCase(); + + // ๋„๋ฉ”์ธ ๋ถ€๋ถ„ ์ถ”์ถœ + var parts = host.split('.'); + + // www ์ œ๊ฑฐ + if (parts.isNotEmpty && parts[0] == 'www') { + parts = parts.sublist(1); + } + + // ์„œ๋ธŒ๋„๋ฉ”์ธ ์ฒ˜๋ฆฌ (์˜ˆ: music.youtube.com) + if (parts.length >= 3) { + // ์„œ๋ธŒ๋„๋ฉ”์ธ ํฌํ•จ ์ „์ฒด ๋„๋ฉ”์ธ ๋ฐ˜ํ™˜ + return parts.sublist(0, parts.length - 1).join('.'); + } else if (parts.length >= 2) { + // ๋ฉ”์ธ ๋„๋ฉ”์ธ๋งŒ ๋ฐ˜ํ™˜ + return parts[0]; + } + + return null; + } catch (e) { + print('UrlMatcherService: ๋„๋ฉ”์ธ ์ถ”์ถœ ์‹คํŒจ - $e'); + return null; + } + } + + /// URL๋กœ ์„œ๋น„์Šค ์ฐพ๊ธฐ + Future findServiceByUrl(String url) async { + final domain = extractDomain(url); + if (domain == null) return null; + + // JSON ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด JSON์—์„œ ์ฐพ๊ธฐ + final servicesData = _dataRepository.getServicesData(); + if (servicesData != null) { + final categories = servicesData['categories'] as Map; + + for (final categoryEntry in categories.entries) { + final categoryId = categoryEntry.key; + final categoryData = categoryEntry.value as Map; + final services = categoryData['services'] as Map; + + for (final serviceEntry in services.entries) { + final serviceId = serviceEntry.key; + final serviceData = serviceEntry.value as Map; + final domains = List.from(serviceData['domains'] ?? []); + + // ๋„๋ฉ”์ธ์ด ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ + for (final serviceDomain in domains) { + if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) { + final names = List.from(serviceData['names'] ?? []); + final urls = serviceData['urls'] as Map?; + + return ServiceInfo( + serviceId: serviceId, + serviceName: names.isNotEmpty ? names[0] : serviceId, + serviceUrl: urls?['kr'] ?? urls?['en'], + cancellationUrl: null, + categoryId: _categoryMapper.getCategoryIdByKey(categoryId), + categoryNameKr: categoryData['nameKr'] ?? '', + categoryNameEn: categoryData['nameEn'] ?? '', + ); + } + } + } + } + } + + // JSON์—์„œ ๋ชป ์ฐพ์•˜์œผ๋ฉด ๋ ˆ๊ฑฐ์‹œ ๋ฐฉ์‹์œผ๋กœ ์ฐพ๊ธฐ + for (final entry in LegacyServiceData.allServices.entries) { + final serviceUrl = entry.value; + final serviceDomain = extractDomain(serviceUrl); + + if (serviceDomain != null && + (domain.contains(serviceDomain) || serviceDomain.contains(domain))) { + return ServiceInfo( + serviceId: entry.key, + serviceName: entry.key, + serviceUrl: serviceUrl, + cancellationUrl: null, + categoryId: _categoryMapper.getCategoryForLegacyService(entry.key), + categoryNameKr: '', + categoryNameEn: '', + ); + } + } + + return null; + } + + /// ์„œ๋น„์Šค๋ช…์œผ๋กœ URL ์ฐพ๊ธฐ + String? suggestUrl(String serviceName) { + if (serviceName.isEmpty) { + print('UrlMatcherService: ๋นˆ serviceName'); + return null; + } + + // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ๋น„๊ต + final lowerName = serviceName.toLowerCase().trim(); + + try { + // ์ •ํ™•ํ•œ ๋งค์นญ์„ ๋จผ์ € ์‹œ๋„ + for (final entry in LegacyServiceData.allServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: ์ •ํ™•ํ•œ ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // OTT ์„œ๋น„์Šค ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.ottServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: OTT ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // ์Œ์•… ์„œ๋น„์Šค ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.musicServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: ์Œ์•… ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // AI ์„œ๋น„์Šค ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.aiServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: AI ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์„œ๋น„์Šค ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.programmingServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: ํ”„๋กœ๊ทธ๋ž˜๋ฐ ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // ์˜คํ”ผ์Šค ํˆด ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.officeTools.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: ์˜คํ”ผ์Šค ํˆด ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // ๊ธฐํƒ€ ์„œ๋น„์Šค ๊ฒ€์‚ฌ + for (final entry in LegacyServiceData.otherServices.entries) { + if (lowerName.contains(entry.key.toLowerCase())) { + print('UrlMatcherService: ๊ธฐํƒ€ ์„œ๋น„์Šค ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + // ์ „์ฒด ์„œ๋น„์Šค์—์„œ ๋ถ€๋ถ„ ๋งค์นญ ์žฌ์‹œ๋„ + for (final entry in LegacyServiceData.allServices.entries) { + final key = entry.key.toLowerCase(); + if (key.contains(lowerName) || lowerName.contains(key)) { + print('UrlMatcherService: ๋ถ€๋ถ„ ๋งค์นญ - $lowerName -> ${entry.key}'); + return entry.value; + } + } + + print('UrlMatcherService: ๋งค์นญ ์‹คํŒจ - $lowerName'); + return null; + } catch (e) { + print('UrlMatcherService: suggestUrl ์—๋Ÿฌ - $e'); + return null; + } + } + + /// URL์ด ์•Œ๋ ค์ง„ ์„œ๋น„์Šค URL์ธ์ง€ ํ™•์ธ + Future isKnownServiceUrl(String url) async { + final serviceInfo = await findServiceByUrl(url); + return serviceInfo != null; + } + + /// ์ž…๋ ฅ๋œ ์„œ๋น„์Šค ์ด๋ฆ„์ด๋‚˜ ๋ฌธ์ž์—ด์—์„œ ๋งค์นญ๋˜๋Š” URL์„ ์ฐพ์•„ ๋ฐ˜ํ™˜ + String? findMatchingUrl(String text, {bool usePartialMatch = true}) { + // ์ž…๋ ฅ ํ…์ŠคํŠธ๊ฐ€ ๋น„์–ด์žˆ๊ฑฐ๋‚˜ null์ธ ๊ฒฝ์šฐ + if (text.isEmpty) { + return null; + } + + // ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ฒ˜๋ฆฌ + final String lowerText = text.toLowerCase().trim(); + + // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ๊ฒฝ์šฐ + if (LegacyServiceData.allServices.containsKey(lowerText)) { + return LegacyServiceData.allServices[lowerText]; + } + + // ๋ถ€๋ถ„ ์ผ์น˜ ๊ฒ€์ƒ‰์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ + if (usePartialMatch) { + // ๊ฐ€์žฅ ๊ธด ๋ถ€๋ถ„ ๋งค์นญ ์ฐพ๊ธฐ + String? bestMatch; + int maxLength = 0; + + for (var entry in LegacyServiceData.allServices.entries) { + final String key = entry.key; + + // ์ž…๋ ฅ๋œ ํ…์ŠคํŠธ์— ์„œ๋น„์Šค ํ‚ค์›Œ๋“œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๊ฑฐ๋‚˜, ์„œ๋น„์Šค ํ‚ค์›Œ๋“œ์— ์ž…๋ ฅ๋œ ํ…์ŠคํŠธ๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ + if (lowerText.contains(key) || key.contains(lowerText)) { + // ๋” ๊ธด ๋งค์นญ์„ ์šฐ์„ ์‹œ + if (key.length > maxLength) { + maxLength = key.length; + bestMatch = entry.value; + } + } + } + + return bestMatch; + } + + return null; + } +} \ No newline at end of file diff --git a/lib/utils/sms_scan/category_icon_mapper.dart b/lib/utils/sms_scan/category_icon_mapper.dart new file mode 100644 index 0000000..3d30f93 --- /dev/null +++ b/lib/utils/sms_scan/category_icon_mapper.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import '../../models/category_model.dart'; + +class CategoryIconMapper { + // ์นดํ…Œ๊ณ ๋ฆฌ ์•„์ด์ฝ˜ ๋ฐ˜ํ™˜ + static IconData getCategoryIcon(CategoryModel category) { + switch (category.name) { + case 'music': + return Icons.music_note_rounded; + case 'ottVideo': + return Icons.movie_filter_rounded; + case 'storageCloud': + return Icons.cloud_outlined; + case 'telecomInternetTv': + return Icons.wifi_rounded; + case 'lifestyle': + return Icons.home_outlined; + case 'shoppingEcommerce': + return Icons.shopping_cart_outlined; + case 'programming': + return Icons.code_rounded; + case 'collaborationOffice': + return Icons.business_center_outlined; + case 'aiService': + return Icons.smart_toy_outlined; + case 'other': + default: + return Icons.category_outlined; + } + } + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ฐ˜ํ™˜ + static Color getCategoryColor(CategoryModel category) { + final colorString = category.color; + try { + return Color(int.parse(colorString.replaceFirst('#', '0xFF'))); + } catch (e) { + // ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๊ธฐ๋ณธ ์ƒ‰์ƒ ๋ฐ˜ํ™˜ + return const Color(0xFF6B7280); // ๊ธฐ๋ณธ ํšŒ์ƒ‰ + } + } + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ์•„์ด์ฝ˜ ํฌ๊ธฐ ๋ฐ˜ํ™˜ + static double getCategoryIconSize(CategoryModel category) { + switch (category.name) { + case 'music': + case 'ottVideo': + return 18.0; + default: + return 16.0; + } + } +} \ No newline at end of file diff --git a/lib/utils/sms_scan/date_formatter.dart b/lib/utils/sms_scan/date_formatter.dart new file mode 100644 index 0000000..2d8353e --- /dev/null +++ b/lib/utils/sms_scan/date_formatter.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; + +class SmsDateFormatter { + // ๋‚ ์งœ ์ƒํƒœ ํ…์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ + static String getNextBillingText( + BuildContext context, + DateTime date, + String billingCycle, + ) { + final now = DateTime.now(); + + if (date.isBefore(now)) { + return _getPastDateText(context, date, billingCycle, now); + } else { + return _getFutureDateText(context, date, now); + } + } + + // ๊ณผ๊ฑฐ ๋‚ ์งœ ์ฒ˜๋ฆฌ + static String _getPastDateText( + BuildContext context, + DateTime date, + String billingCycle, + DateTime now, + ) { + // ์ฃผ๊ธฐ์— ๋”ฐ๋ผ ๋‹ค์Œ ๊ฒฐ์ œ์ผ ์˜ˆ์ธก + DateTime? predictedDate = _predictNextBillingDate(date, billingCycle, now); + + if (predictedDate != null) { + final daysUntil = predictedDate.difference(now).inDays; + return AppLocalizations.of(context).nextBillingDateEstimated( + AppLocalizations.of(context).formatDate(predictedDate), + daysUntil, + ); + } + + return '๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ™•์ธ ํ•„์š” (๊ณผ๊ฑฐ ๋‚ ์งœ)'; + } + + // ๋ฏธ๋ž˜ ๋‚ ์งœ ์ฒ˜๋ฆฌ + static String _getFutureDateText( + BuildContext context, + DateTime date, + DateTime now, + ) { + final daysUntil = date.difference(now).inDays; + return AppLocalizations.of(context).nextBillingDateInfo( + AppLocalizations.of(context).formatDate(date), + daysUntil, + ); + } + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ ์˜ˆ์ธก + static DateTime? _predictNextBillingDate( + DateTime lastDate, + String billingCycle, + DateTime now, + ) { + switch (billingCycle) { + case '์›”๊ฐ„': + return _getNextMonthlyDate(lastDate, now); + case '์—ฐ๊ฐ„': + return _getNextYearlyDate(lastDate, now); + case '์ฃผ๊ฐ„': + return _getNextWeeklyDate(lastDate, now); + case '์ผ๊ฐ„': + return _getNextDailyDate(lastDate, now); + case '๋ถ„๊ธฐ๋ณ„': + return _getNextQuarterlyDate(lastDate, now); + case '๋ฐ˜๊ธฐ๋ณ„': + return _getNextSemiAnnuallyDate(lastDate, now); + default: + return null; + } + } + + // ๋‹ค์Œ ์›”๊ฐ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextMonthlyDate(DateTime lastDate, DateTime now) { + int day = lastDate.day; + + // ํ˜„์žฌ ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • + final lastDay = DateTime(now.year, now.month + 1, 0).day; + if (day > lastDay) { + day = lastDay; + } + + DateTime adjusted = DateTime(now.year, now.month, day); + if (adjusted.isBefore(now)) { + // ๋‹ค์Œ ๋‹ฌ์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • + final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day; + if (day > nextMonthLastDay) { + day = nextMonthLastDay; + } + adjusted = DateTime(now.year, now.month + 1, day); + } + + return adjusted; + } + + // ๋‹ค์Œ ์—ฐ๊ฐ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextYearlyDate(DateTime lastDate, DateTime now) { + int day = lastDate.day; + + // ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • + final lastDay = DateTime(now.year, lastDate.month + 1, 0).day; + if (day > lastDay) { + day = lastDay; + } + + DateTime adjusted = DateTime(now.year, lastDate.month, day); + if (adjusted.isBefore(now)) { + // ๋‹ค์Œ ํ•ด ํ•ด๋‹น ์›”์˜ ๋งˆ์ง€๋ง‰ ๋‚ ์„ ์ดˆ๊ณผํ•˜๋Š” ๊ฒฝ์šฐ ์กฐ์ • + final nextYearLastDay = DateTime(now.year + 1, lastDate.month + 1, 0).day; + if (day > nextYearLastDay) { + day = nextYearLastDay; + } + adjusted = DateTime(now.year + 1, lastDate.month, day); + } + + return adjusted; + } + + // ๋‹ค์Œ ์ฃผ๊ฐ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextWeeklyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = next.add(const Duration(days: 7)); + } + return next; + } + + // ๋‹ค์Œ ์ผ๊ฐ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextDailyDate(DateTime lastDate, DateTime now) { + return now.add(const Duration(days: 1)); + } + + // ๋‹ค์Œ ๋ถ„๊ธฐ๋ณ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextQuarterlyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = DateTime(next.year, next.month + 3, next.day); + } + return next; + } + + // ๋‹ค์Œ ๋ฐ˜๊ธฐ๋ณ„ ๊ฒฐ์ œ์ผ ๊ณ„์‚ฐ + static DateTime _getNextSemiAnnuallyDate(DateTime lastDate, DateTime now) { + DateTime next = lastDate; + while (next.isBefore(now)) { + next = DateTime(next.year, next.month + 6, next.day); + } + return next; + } + + // ๋‚ ์งœ ํฌ๋งท ํ•จ์ˆ˜ + static String formatDate(DateTime date) { + return '${date.year}๋…„ ${date.month}์›” ${date.day}์ผ'; + } + + // ๊ฒฐ์ œ ๋ฐ˜๋ณต ํšŸ์ˆ˜ ํ…์ŠคํŠธ + static String getRepeatCountText(BuildContext context, int count) { + return AppLocalizations.of(context).repeatCountDetected(count); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_initial_widget.dart b/lib/widgets/sms_scan/scan_initial_widget.dart new file mode 100644 index 0000000..bf2059b --- /dev/null +++ b/lib/widgets/sms_scan/scan_initial_widget.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; +import '../../widgets/common/buttons/primary_button.dart'; +import '../../widgets/native_ad_widget.dart'; +import '../../l10n/app_localizations.dart'; + +class ScanInitialWidget extends StatelessWidget { + final VoidCallback onScanPressed; + final String? errorMessage; + + const ScanInitialWidget({ + super.key, + required this.onScanPressed, + this.errorMessage, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // ๊ด‘๊ณ  ์œ„์ ฏ ์ถ”๊ฐ€ + const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), + const SizedBox(height: 48), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: ThemedText( + errorMessage!, + color: Colors.red, + textAlign: TextAlign.center, + ), + ), + ThemedText( + AppLocalizations.of(context).findRepeatSubscriptions, + fontSize: 20, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ThemedText( + AppLocalizations.of(context).scanTextMessages, + textAlign: TextAlign.center, + opacity: 0.7, + forceDark: true, + ), + ), + const SizedBox(height: 32), + PrimaryButton( + text: AppLocalizations.of(context).startScanning, + icon: Icons.search_rounded, + onPressed: onScanPressed, + width: 200, + height: 56, + backgroundColor: AppColors.primaryColor, + ), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_loading_widget.dart b/lib/widgets/sms_scan/scan_loading_widget.dart new file mode 100644 index 0000000..ad28115 --- /dev/null +++ b/lib/widgets/sms_scan/scan_loading_widget.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; +import '../../l10n/app_localizations.dart'; + +class ScanLoadingWidget extends StatelessWidget { + const ScanLoadingWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primaryColor), + ), + const SizedBox(height: 16), + ThemedText( + AppLocalizations.of(context).scanningMessages, + forceDark: true, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).findingSubscriptions, + opacity: 0.7, + forceDark: true, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/scan_progress_widget.dart b/lib/widgets/sms_scan/scan_progress_widget.dart new file mode 100644 index 0000000..7a830fc --- /dev/null +++ b/lib/widgets/sms_scan/scan_progress_widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../widgets/themed_text.dart'; + +class ScanProgressWidget extends StatelessWidget { + final int currentIndex; + final int totalCount; + + const ScanProgressWidget({ + super.key, + required this.currentIndex, + required this.totalCount, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ์ง„ํ–‰ ์ƒํƒœ ํ‘œ์‹œ + LinearProgressIndicator( + value: (currentIndex + 1) / totalCount, + backgroundColor: AppColors.navyGray.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + ThemedText( + '${currentIndex + 1}/$totalCount', + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/sms_scan/subscription_card_widget.dart b/lib/widgets/sms_scan/subscription_card_widget.dart new file mode 100644 index 0000000..83de2a6 --- /dev/null +++ b/lib/widgets/sms_scan/subscription_card_widget.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/subscription.dart'; +import '../../providers/category_provider.dart'; +import '../../providers/locale_provider.dart'; +import '../../widgets/glassmorphism_card.dart'; +import '../../widgets/themed_text.dart'; +import '../../widgets/common/buttons/primary_button.dart'; +import '../../widgets/common/buttons/secondary_button.dart'; +import '../../widgets/common/form_fields/base_text_field.dart'; +import '../../widgets/common/form_fields/category_selector.dart'; +import '../../widgets/common/snackbar/app_snackbar.dart'; +import '../../widgets/native_ad_widget.dart'; +import '../../theme/app_colors.dart'; +import '../../services/currency_util.dart'; +import '../../utils/sms_scan/date_formatter.dart'; +import '../../utils/sms_scan/category_icon_mapper.dart'; +import '../../l10n/app_localizations.dart'; + +class SubscriptionCardWidget extends StatefulWidget { + final Subscription subscription; + final TextEditingController websiteUrlController; + final String? selectedCategoryId; + final Function(String?) onCategoryChanged; + final VoidCallback onAdd; + final VoidCallback onSkip; + + const SubscriptionCardWidget({ + super.key, + required this.subscription, + required this.websiteUrlController, + this.selectedCategoryId, + required this.onCategoryChanged, + required this.onAdd, + required this.onSkip, + }); + + @override + State createState() => _SubscriptionCardWidgetState(); +} + +class _SubscriptionCardWidgetState extends State { + @override + void initState() { + super.initState(); + // URL ํ•„๋“œ ์ž๋™ ์„ค์ • + if (widget.websiteUrlController.text.isEmpty && widget.subscription.websiteUrl != null) { + widget.websiteUrlController.text = widget.subscription.websiteUrl!; + } + } + + void _handleCardTap() { + // ๋””๋ฒ„๊ทธ ๋กœ๊ทธ ์ถ”๊ฐ€ + print('[SubscriptionCard] Card tapped! Service: ${widget.subscription.serviceName}'); + + // ๊ตฌ๋… ์นด๋“œ ํด๋ฆญ ์‹œ ์ฒ˜๋ฆฌ + AppSnackBar.showInfo( + context: context, + message: '์ด ๊ธฐ๋Šฅ์€ ๊ณง ์ถœ์‹œ๋ฉ๋‹ˆ๋‹ค', // ์ž„์‹œ๋กœ ํ•˜๋“œ์ฝ”๋”ฉ + icon: Icons.info_outline, + ); + } + + @override + Widget build(BuildContext context) { + final categoryProvider = Provider.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ๊ด‘๊ณ  ์œ„์ ฏ ์ถ”๊ฐ€ + const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ๊ตฌ๋… ์ •๋ณด ์นด๋“œ + GlassmorphismCard( + width: double.infinity, + padding: EdgeInsets.zero, + child: Column( + children: [ + // ํด๋ฆญ ๊ฐ€๋Šฅํ•œ ์ •๋ณด ์˜์—ญ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleCardTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInfoSection(categoryProvider), + ), + ), + + // ๊ตฌ๋ถ„์„  + Container( + height: 1, + color: AppColors.navyGray.withValues(alpha: 0.1), + ), + + // ํด๋ฆญ ๋ถˆ๊ฐ€๋Šฅํ•œ ์•ก์…˜ ์˜์—ญ + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildActionSection(categoryProvider), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + // ์ •๋ณด ์„น์…˜ (ํด๋ฆญ ๊ฐ€๋Šฅ) + Widget _buildInfoSection(CategoryProvider categoryProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).foundSubscription, + fontSize: 18, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 24), + + // ์„œ๋น„์Šค๋ช… + ThemedText( + AppLocalizations.of(context).serviceName, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + widget.subscription.serviceName, + fontSize: 22, + fontWeight: FontWeight.bold, + forceDark: true, + ), + const SizedBox(height: 16), + + // ๊ธˆ์•ก ๋ฐ ๊ฒฐ์ œ ์ฃผ๊ธฐ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).monthlyCost, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + // ์–ธ์–ด๋ณ„ ํ†ตํ™” ํ‘œ์‹œ + FutureBuilder( + future: CurrencyUtil.formatAmountWithLocale( + widget.subscription.monthlyCost, + widget.subscription.currency, + context.read().locale.languageCode, + ), + builder: (context, snapshot) { + return ThemedText( + snapshot.data ?? '-', + fontSize: 18, + fontWeight: FontWeight.bold, + forceDark: true, + ); + }, + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + AppLocalizations.of(context).billingCycle, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + widget.subscription.billingCycle, + fontSize: 16, + fontWeight: FontWeight.w500, + forceDark: true, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ + ThemedText( + AppLocalizations.of(context).nextBillingDateLabel, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + SmsDateFormatter.getNextBillingText( + context, + widget.subscription.nextBillingDate, + widget.subscription.billingCycle, + ), + fontSize: 16, + fontWeight: FontWeight.w500, + forceDark: true, + ), + ], + ); + } + + // ์•ก์…˜ ์„น์…˜ (ํด๋ฆญ ๋ถˆ๊ฐ€๋Šฅ) + Widget _buildActionSection(CategoryProvider categoryProvider) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ + ThemedText( + AppLocalizations.of(context).category, + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 8), + CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: widget.selectedCategoryId ?? widget.subscription.category, + onChanged: widget.onCategoryChanged, + baseColor: _getCategoryColor(categoryProvider), + isGlassmorphism: true, + ), + const SizedBox(height: 24), + + // ์›น์‚ฌ์ดํŠธ URL ์ž…๋ ฅ ํ•„๋“œ + BaseTextField( + controller: widget.websiteUrlController, + label: AppLocalizations.of(context).websiteUrlAuto, + hintText: AppLocalizations.of(context).websiteUrlHint, + prefixIcon: Icon( + Icons.language, + color: AppColors.navyGray, + ), + style: TextStyle( + color: AppColors.darkNavy, + ), + fillColor: AppColors.pureWhite.withValues(alpha: 0.8), + ), + const SizedBox(height: 32), + + // ์ž‘์—… ๋ฒ„ํŠผ + Row( + children: [ + Expanded( + child: SecondaryButton( + text: AppLocalizations.of(context).skip, + onPressed: widget.onSkip, + height: 48, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + text: AppLocalizations.of(context).add, + onPressed: widget.onAdd, + height: 48, + ), + ), + ], + ), + ], + ); + } + + Color? _getCategoryColor(CategoryProvider categoryProvider) { + final categoryId = widget.selectedCategoryId ?? widget.subscription.category; + if (categoryId == null) return null; + + final category = categoryProvider.getCategoryById(categoryId); + if (category == null) return null; + + return CategoryIconMapper.getCategoryColor(category); + } +} \ No newline at end of file