diff --git a/Makefile b/Makefile index eecddb7f1..6c9877609 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,4 @@ fastlane-mainnet: fastlane-regtest: cd android && caffeinate -dimsu bundle exec fastlane release_android_regtest && cd .. && cd ios && caffeinate -dimsu bundle exec fastlane release_ios_regtest skip_prep:true - \ No newline at end of file + diff --git a/assets/i18n/en.i18n.yaml b/assets/i18n/en.i18n.yaml index 5422d9e10..44d5074d0 100644 --- a/assets/i18n/en.i18n.yaml +++ b/assets/i18n/en.i18n.yaml @@ -99,6 +99,12 @@ block_explorer: "Block Explorer" log_viewer: "Log Viewer" go_to_settings: "Go to Settings" +relative_time: + days_ago(cardinal): + zero: "Today" # 0일이면 Today로 처리(원하면 생략 가능) + one: "1 day ago" # 1일 + other: "${n} days ago" # 2일 이상 + apply_item: "$count items" fee_sats: " ($value sat/vB)" utxo_count: "($count items)" @@ -187,6 +193,52 @@ pin_check_screen: text: "Enter your password" # lib/screens/home +wallet_home_screen: + view_all_wallets: "View All My Wallets" + edit_home_screen: "Edit Home Screen" + edit: + title: "You can hide or add features\non the home screen" + hide_balance: "Hide Balance" + hide_balance_on_home: "Balance will not be displayed on the home screen." + fake_balance: + fake_balance_setting: "Set Fake Balance" + fake_balance_display: "Show Fake Balance" + fake_balance_input_placeholder: "Please set the total fake balance (in BTC units)" + fake_balance_input_description: "If you have multiple wallets, each wallet's balance will be calculated automatically." + fake_balance_input_exceeds_error: "You can set up to 21 million BTC" + hide_fiat_price: "Hide Fiat Balance" + hide_fiat_price_on_home: "Fiat balance will not be displayed on the home screen." + category: + total_balance: "Total\nBalance" + wallet_list: "Wallet\nList" + recent_transactions: "Recent\nTransactions (24 hours)" + analysis: "Analysis" + alert: + empty_fake_balance: "Enter Fake Balance" + empty_fake_balance_description: "No amount has been entered.\nWould you like to enter it again or set it to 0 BTC?" + set_to_0: "Set balance to 0 BTC" + enter_again: "Try again" + empty_recent_transaction: "No recent 24 hours transactions" + syncing_recent_transaction: "Syncing recent transactions" + empty_analysis_result: "No transactions needed for analysis" + analysis_period: "Recent ${days} days • ${transaction_type}" + analysis_period_custom: "Custom • ${transaction_type}" + analysis_period_bottom_sheet: + period_for_analysis: "Analysis Period" + days_15: "15 days" + days_30: "30 days" + days_60: "60 days" + days_90: "90 days" + custom: "Custom" + transaction_type: "Transaction Type" + + transaction_count: "Transaction $count times" + count: "$count times" + increase: "Increased" + decrease: "Decreased" + sent: "Sent" + received: "Received" + wallet_add_scanner_screen: add_wallet: "Add Wallet" add_watch_only_wallet: "Add Watch-only Wallet" @@ -254,7 +306,6 @@ wallet_add_scanner_screen: # lib/screens/wallet_list wallet_list: wallet_count: "$count Items" - view_all_wallets: "View All My Wallets" primary_wallet: "Primary Wallet" exclude_from_total_amount: "Excluded from Home Total" home_balance: "Home Balance" @@ -456,13 +507,6 @@ settings_screen: set_password: "Set Password" use_biometric: "Use Biometric Auth" change_password: "Change Password" - hide_balance: "Hide Balance" - fake_balance: - fake_balance_setting: "Set Fake Balance" - fake_balance_display: "Show Fake Balance" - fake_balance_input_placeholder: "Please set the total fake balance (in BTC units)" - fake_balance_input_description: "If you have multiple wallets, each wallet's balance will be calculated automatically." - fake_balance_input_exceeds_error: "You can set up to 21 million BTC" language: "Language" korean: "한국어" english: "English" diff --git a/assets/i18n/jp.i18n.yaml b/assets/i18n/jp.i18n.yaml index a7d483be0..7066955ab 100644 --- a/assets/i18n/jp.i18n.yaml +++ b/assets/i18n/jp.i18n.yaml @@ -187,6 +187,52 @@ pin_check_screen: text: "パスワードを入力してください" # lib/screens/home +wallet_home_screen: + view_all_wallets: "マイウォレット一覧" + edit_home_screen: "ホーム画面を編集" + edit: + title: "ホーム画面で機能を\n非表示にしたり、追加したりできます" + hide_balance: "残高を非表示" + hide_balance_on_home: "ホーム画面に残高を表示しません。" + fake_balance: + fake_balance_setting: "フェイク残高設定" + fake_balance_display: "フェイク残高表示" + fake_balance_input_placeholder: "偽の総残高を設定してください(BTC単位)" + fake_balance_input_description: "複数のウォレットがある場合、各ウォレットの残高は自動的に計算されます。" + fake_balance_input_exceeds_error: "最大2100万BTCまで設定できます。" + hide_fiat_price: "フィアット残高を非表示" + hide_fiat_price_on_home: "ホーム画面にフィアット残高を表示しません。" + category: + total_balance: "残高合計" + wallet_list: "ウォレット\n一覧" + recent_transactions: "最近の\n取引(24時間)" + analysis: "分析" + alert: + empty_fake_balance: "フェイク残高を入力" + empty_fake_balance_description: "金額が設定されていません。\nもう一度入力するか、0 BTCに設定しますか?" + set_to_0: "0 BTCに設定" + enter_again: "再入力" + empty_recent_transaction: "最近24時間の取引がありません" + syncing_recent_transaction: "最近の取引履歴を読み込んでいます" + empty_analysis_result: "分析に必要な取引はありません" + analysis_period: "直近${days}日 • ${transaction_type}" + analysis_period_cutsom: "直接設定 • ${transaction_type}" + analysis_period_bottom_sheet: + period_for_analysis: "照会期間" + days_15: "15日" + days_30: "30日" + days_60: "60日" + days_90: "90日" + custom: "直接設定" + transaction_type: "取引タイプ" + + transaction_count: "取引 $count回" + count: "$count回" + increase: "増加しました" + decrease: "減少しました" + sent: "送りました" + received: "いただきました" + wallet_add_scanner_screen: add_wallet: "ウォレット追加" add_watch_only_wallet: "ウォッチオンリー\nウォレット追加" @@ -457,12 +503,6 @@ settings_screen: use_biometric: "生体認証を使用" change_password: "パスワード変更" hide_balance: "残高を非表示" - fake_balance: - fake_balance_setting: "偽残高設定" - fake_balance_display: "偽残高を表示" - fake_balance_input_placeholder: "偽の総残高を設定してください(BTC単位)" - fake_balance_input_description: "複数のウォレットがある場合、各ウォレットの残高は自動的に計算されます。" - fake_balance_input_exceeds_error: "最大2100万BTCまで設定できます" language: "言語" korean: "한국어" english: "English" diff --git a/assets/i18n/kr.i18n.yaml b/assets/i18n/kr.i18n.yaml index 233539580..ed71c6d98 100644 --- a/assets/i18n/kr.i18n.yaml +++ b/assets/i18n/kr.i18n.yaml @@ -99,6 +99,12 @@ block_explorer: "블록 익스플로러" log_viewer: "로그 뷰어" go_to_settings: "설정하러 가기" +relative_time: + days_ago(cardinal): + zero: "오늘" # 0일이면 Today로 처리(원하면 생략 가능) + one: "1일 전" # 1일 + other: "${n}일 전" # 2일 이상 + apply_item: "$count개에 적용" fee_sats: " ($value sat/vB)" utxo_count: "($count개)" @@ -187,6 +193,52 @@ pin_check_screen: text: "비밀번호를 눌러주세요" # lib/screens/home +wallet_home_screen: + view_all_wallets: "내 지갑 전체보기" + edit_home_screen: "홈 화면 편집하기" + edit: + title: "홈 화면에 기능을\n숨기거나 추가할 수 있어요" + hide_balance: "잔액 숨기기" + hide_balance_on_home: "홈 화면에 잔액을 표시하지 않아요." + fake_balance: + fake_balance_setting: "가짜 잔액 설정" + fake_balance_display: "가짜 잔액 표시" + fake_balance_input_placeholder: "총 가짜 잔액을 설정해 주세요.(BTC 단위)" + fake_balance_input_description: "여러 지갑이 있을 경우, 각 지갑의 잔액은 자동으로 계산돼요." + fake_balance_input_exceeds_error: "최대 2100만 BTC까지 설정할 수 있어요" + hide_fiat_price: "법정 화폐 잔액 숨기기" + hide_fiat_price_on_home: "홈 화면에 법정 화폐 잔액을 표시하지 않아요." + category: + total_balance: "잔액 합계" + wallet_list: "지갑 목록" + recent_transactions: "최근 거래\n(24시간)" + analysis: "분석" + alert: + empty_fake_balance: "가짜 잔액 입력" + empty_fake_balance_description: "금액이 설정되지 않았어요.\n다시 입력하시거나 0 BTC로 설정할까요?" + set_to_0: "0 BTC로 설정" + enter_again: "다시 입력" + empty_recent_transaction: "최근 24시간 동안의 거래가 없어요" + syncing_recent_transaction: "최근 거래 내역을 불러오는 중이에요" + empty_analysis_result: "분석에 필요한 거래가 없어요" + analysis_period: "최근 ${days}일 • ${transaction_type}" + analysis_period_cutsom: "직접 설정 • ${transaction_type}" + analysis_period_bottom_sheet: + period_for_analysis: "조회 기간" + days_15: "15일" + days_30: "30일" + days_60: "60일" + days_90: "90일" + custom: "직접 설정" + transaction_type: "거래 유형" + + transaction_count: "트랜잭션 $count회" + count: "$count회" + increase: "증가했어요" + decrease: "감소했어요" + sent: "보냈어요" + received: "받았어요" + wallet_add_scanner_screen: add_wallet: "지갑 추가" add_watch_only_wallet: "보기 전용 지갑 추가" @@ -254,7 +306,6 @@ wallet_add_scanner_screen: # lib/screens/wallet_list wallet_list: wallet_count: "$count개" - view_all_wallets: "내 지갑 전체보기" primary_wallet: "대표 지갑" exclude_from_total_amount: "홈 화면 총액에서 제외" home_balance: "홈 화면 총액" @@ -456,13 +507,6 @@ settings_screen: set_password: "비밀번호 설정하기" use_biometric: "생체 인증 사용하기" change_password: "비밀번호 바꾸기" - hide_balance: "홈 화면 잔액 숨기기" - fake_balance: - fake_balance_setting: "가짜 잔액 설정" - fake_balance_display: "가짜 잔액 표시" - fake_balance_input_placeholder: "총 가짜 잔액을 설정해 주세요.(BTC 단위)" - fake_balance_input_description: "여러 지갑이 있을 경우, 각 지갑의 잔액은 자동으로 계산돼요." - fake_balance_input_exceeds_error: "최대 2100만 BTC까지 설정할 수 있어요" language: "언어" korean: "한국어" english: "English" @@ -834,7 +878,7 @@ trade_exchange: binance: "바이낸스" fiat: - fiat: "법정화폐" + fiat: "법정 화폐" krw_price: "업비트 기준" usd_price: "바이낸스 기준" jpy_price: "비트플라이어 기준" diff --git a/assets/svg/analysis.svg b/assets/svg/analysis.svg new file mode 100644 index 000000000..2a5dccc9c --- /dev/null +++ b/assets/svg/analysis.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/svg/piggy-bank.svg b/assets/svg/piggy-bank.svg new file mode 100644 index 000000000..1af68bb45 --- /dev/null +++ b/assets/svg/piggy-bank.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/svg/transaction.svg b/assets/svg/transaction.svg new file mode 100644 index 000000000..24665d54b --- /dev/null +++ b/assets/svg/transaction.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/svg/wallet.svg b/assets/svg/wallet.svg new file mode 100644 index 000000000..f7062fd8e --- /dev/null +++ b/assets/svg/wallet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lib/app.dart b/lib/app.dart index 7c516803a..e0052874b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,7 +4,8 @@ import 'package:coconut_wallet/app_guard.dart'; import 'package:coconut_wallet/providers/auth_provider.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/feature_settings_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; @@ -58,6 +59,7 @@ import 'package:flutter/material.dart'; import 'package:coconut_wallet/screens/common/pin_check_screen.dart'; import 'package:coconut_wallet/screens/onboarding/start_screen.dart'; import 'package:coconut_wallet/widgets/custom_loading_overlay.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/services/analytics_service.dart'; @@ -98,6 +100,7 @@ class _CoconutWalletAppState extends State { ChangeNotifierProvider(create: (_) => VisibilityProvider()), ChangeNotifierProvider(create: (_) => ConnectivityProvider()), ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => FeatureSettingsProvider()), Provider.value(value: _realmManager), @@ -119,9 +122,21 @@ class _CoconutWalletAppState extends State { create: (context) => WalletPreferencesRepository(context.read()), ), - ChangeNotifierProvider(create: (_) => PreferenceProvider(context.read())), + ChangeNotifierProvider( + create: + (context) => PreferenceProvider( + context.read(), + featureSettingsProvider: context.read(), + ), + ), - ChangeNotifierProvider(create: (context) => PreferenceProvider(context.read())), + ChangeNotifierProvider( + create: + (context) => PreferenceProvider( + context.read(), + featureSettingsProvider: context.read(), + ), + ), ChangeNotifierProvider( create: (context) => PriceProvider(context.read(), context.read()), diff --git a/lib/constants/shared_pref_keys.dart b/lib/constants/shared_pref_keys.dart index e5d1b1e4e..55492f75e 100644 --- a/lib/constants/shared_pref_keys.dart +++ b/lib/constants/shared_pref_keys.dart @@ -19,10 +19,22 @@ class SharedPrefKeys { static const String kIsReceivingTooltipDisabled = "IS_RECEIVING_TOOLTIP_DISABLED"; static const String kIsChangeTooltipDisabled = "IS_CHANGE_TOOLTIP_DISABLED"; static const String kIsBalanceHidden = "IS_BALANCE_HIDDEN"; + static const String kIsFiatBalanceHidden = "IS_FIAT_BALANCE_HIDDEN"; static const String kHideTermsShortcut = "IS_OPEN_TERMS_SCREEN"; static const String kNextIdField = 'nextId'; static const String kUtxoSortOrder = 'UTXO_SORT_ORDER'; + /// Home Features + static const String kWalletOrder = "WALLET_ORDER"; // 지갑 순서 + static const String kFavoriteWalletIds = "FAVORITE_WALLET_IDS"; // 즐겨찾기된 지갑 목록 + static const String kExcludedFromTotalBalanceWalletIds = + "EXCLUDED_FROM_TOTAL_BALANCE_WALLET_IDS"; // 홈화면 총 잔액에서 제외할 지갑 목록 + static const String kHomeFeatures = "HOME_FEATURES"; // 홈 화면에 표시할 기능(최근 거래, 분석, ...) + static const String kAnalysisPeriod = "ANALYSIS_PERIOD"; // 분석 위젯에 사용되는 조회 기간 + static const String kAnalysisPeriodStart = "ANALYSIS_PERIOD_START"; // 분석 위젯에 사용되는 조회 기간 시작 날짜 + static const String kAnalysisPeriodEnd = "ANALYSIS_PERIOD_END"; // 분석 위젯에 사용되는 조회 기간 종료 날짜 + static const String kSelectedTransactionTypeIndices = "SELECTED_TRANSACTION_TYPE_INDICES"; // 분석 위젯에 사용되는 거래 유형 + /// 리뷰 요청 관련 static const String kHaveSent = 'HAVE_SENT'; static const String kHaveReviewed = 'HAVE_REVIEWED'; diff --git a/lib/model/preference/home_feature.dart b/lib/model/preference/home_feature.dart new file mode 100644 index 000000000..6d59f528a --- /dev/null +++ b/lib/model/preference/home_feature.dart @@ -0,0 +1,32 @@ +// 내용 변경시 RealmHomeFeature도 수정 필요 +class HomeFeature { + final String homeFeatureTypeString; + final bool isEnabled; + const HomeFeature({required this.homeFeatureTypeString, required this.isEnabled}); + + Map toJson() => {'homeFeatureTypeString': homeFeatureTypeString, 'isEnabled': isEnabled}; + + factory HomeFeature.fromJson(Map json) => + HomeFeature(homeFeatureTypeString: json['homeFeatureTypeString'], isEnabled: json['isEnabled']); +} + +// RealmHomeFeature 수정 불필요 +enum HomeFeatureType { + totalBalance, + walletList, + recentTransaction, + analysis; + + String get assetPath { + switch (this) { + case HomeFeatureType.totalBalance: + return 'assets/svg/piggy-bank.svg'; + case HomeFeatureType.walletList: + return 'assets/svg/wallet.svg'; + case HomeFeatureType.recentTransaction: + return 'assets/svg/transaction.svg'; + case HomeFeatureType.analysis: + return 'assets/svg/analysis.svg'; + } + } +} diff --git a/lib/providers/node_provider/node_provider.dart b/lib/providers/node_provider/node_provider.dart index 1c250b5aa..152721c46 100644 --- a/lib/providers/node_provider/node_provider.dart +++ b/lib/providers/node_provider/node_provider.dart @@ -45,6 +45,11 @@ class NodeProvider extends ChangeNotifier { final _syncStateController = StreamController.broadcast(); final _walletStateController = StreamController>.broadcast(); + final _currentBlockController = StreamController.broadcast(); + + final ValueNotifier _currentBlockNotifier = ValueNotifier(null); + Timer? _blockUpdateTimer; + ValueNotifier get currentBlockNotifier => _currentBlockNotifier; /// 전체 동기화 상태를 구독할 수 있는 스트림 Stream get syncStateStream { @@ -74,6 +79,37 @@ class NodeProvider extends ChangeNotifier { }); } + /// 현재 블록 높이 상태를 구독할 수 있는 스트림 + Stream get currentBlockStream { + return Stream.multi((controller) { + controller.add(_currentBlockNotifier.value); + + final subscription = _currentBlockController.stream.listen(controller.add); + controller.onCancel = () => subscription.cancel(); + }); + } + + Future _updateCurrentBlock() async { + final result = await getLatestBlock(); + if (result.isSuccess) { + Logger.log('NodeProvider: 현재 블록 높이 업데이트 시작 - ${result.value.height}'); + // 블록 높이가 변경되었을 때만 업데이트 + if (_currentBlockNotifier.value?.height != result.value.height) { + _currentBlockNotifier.value = result.value; + _currentBlockController.add(result.value); + Logger.log('NodeProvider: 현재 블록 높이 업데이트 - ${_currentBlockNotifier.value?.height}'); + } + } else { + Logger.error('NodeProvider: 블록 높이 업데이트 실패 - ${result.error}'); + } + } + + void _startBlockUpdates() { + _blockUpdateTimer = Timer.periodic(const Duration(minutes: 10), (timer) { + _updateCurrentBlock(); + }); + } + NodeProviderState get state => _stateManager?.state ?? NodeProviderState.initial(); bool get isInitialized => _initCompleter?.isCompleted ?? false; String get host => _electrumServer.host; @@ -81,6 +117,7 @@ class NodeProvider extends ChangeNotifier { bool get ssl => _electrumServer.ssl; bool get isServerChanging => _isServerChanging; bool get hasConnectionError => _hasConnectionError; + int get currentBlockHeight => _currentBlockNotifier.value?.height ?? 0; NodeProvider( this._electrumServer, @@ -145,12 +182,16 @@ class NodeProvider extends ChangeNotifier { if (_isNetworkInitialized && _isFirstInitialization) { _subscribeInitialWallets(); } + + _updateCurrentBlock(); + _startBlockUpdates(); } } void _subscribeInitialWallets() { _isFirstInitialization = false; subscribeWallets().then((result) { + _updateCurrentBlock(); if (result.isFailure) { Logger.error('NodeProvider: 초기 지갑 구독 실패: ${result.error}'); _stateManager?.setNodeSyncStateToFailed(); @@ -256,6 +297,11 @@ class NodeProvider extends ChangeNotifier { if (_isWalletLoaded && _isFirstInitialization) { _subscribeInitialWallets(); } + + // 초기화 완료 후 블록 높이 업데이트 시작 + Logger.log('NodeProvider: 초기화 완료 후 블록 높이 업데이트 시작'); + _updateCurrentBlock(); + _startBlockUpdates(); } catch (e) { Logger.error('NodeProvider: 초기화 중 오류 발생: $e'); @@ -384,6 +430,8 @@ class NodeProvider extends ChangeNotifier { if (result.isSuccess) { Logger.log('NodeProvider: Reconnect completed successfully'); _setConnectionError(false); + _updateCurrentBlock(); + _startBlockUpdates(); } else { Logger.error('NodeProvider: subscribeWallets failed: ${result.error}'); _stateManager?.setNodeSyncStateToFailed(); @@ -423,6 +471,10 @@ class NodeProvider extends ChangeNotifier { _stateSubscription?.cancel(); _stateSubscription = null; + // 현재 블록 높이 업데이트 중단 + _blockUpdateTimer?.cancel(); + _blockUpdateTimer = null; + // Isolate 정리 await _isolateManager.closeIsolate(); @@ -518,6 +570,7 @@ class NodeProvider extends ChangeNotifier { // Stream Controllers 정리 _syncStateController.close(); _walletStateController.close(); + _currentBlockController.close(); super.dispose(); } diff --git a/lib/providers/preferences/feature_settings_provider.dart b/lib/providers/preferences/feature_settings_provider.dart new file mode 100644 index 000000000..a176dd91e --- /dev/null +++ b/lib/providers/preferences/feature_settings_provider.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:coconut_wallet/constants/shared_pref_keys.dart'; +import 'package:coconut_wallet/model/preference/home_feature.dart'; +import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; +import 'package:coconut_wallet/providers/view_model/home/wallet_home_view_model.dart'; +import 'package:coconut_wallet/repository/shared_preference/shared_prefs_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; +import 'package:tuple/tuple.dart'; + +class FeatureSettingsProvider extends ChangeNotifier { + final SharedPrefsRepository _sharedPrefs = SharedPrefsRepository(); + List _features = []; + + // 분석 위젯 관련 설정 + late int _analysisPeriod; + late Tuple2 _analysisPeriodRange; + late AnalysisTransactionType _selectedAnalysisTransactionType; + + FeatureSettingsProvider() { + // 생성 시 동기적으로 초기화 + _loadFeaturesSync(); + _loadAnalysisSettingsSync(); + } + + List get features => _features; + + // 분석 위젯 설정 getters + int get analysisPeriod => _analysisPeriod; + Tuple2 get analysisPeriodRange => _analysisPeriodRange; + AnalysisTransactionType get selectedAnalysisTransactionType => _selectedAnalysisTransactionType; + + void _loadFeaturesSync() { + final encoded = _sharedPrefs.getString(SharedPrefKeys.kHomeFeatures); + if (encoded.isEmpty) { + _features = []; + return; + } + try { + final List decoded = jsonDecode(encoded); + _features = decoded.map((e) => HomeFeature.fromJson(e as Map)).toList(); + } catch (e) { + // JSON 파싱 실패 시 빈 리스트로 초기화 + _features = []; + } + } + + /// 특정 기능이 활성화되어 있는지 확인 + bool isEnabled(HomeFeatureType type) { + return _features + .firstWhere( + (f) => f.homeFeatureTypeString == type.name, + orElse: () => HomeFeature(homeFeatureTypeString: type.name, isEnabled: false), + ) + .isEnabled; + } + + /// 저장소에서 Feature 리스트 로드 + Future loadFeatures() async { + final encoded = _sharedPrefs.getString(SharedPrefKeys.kHomeFeatures); + if (encoded.isEmpty) { + _features = []; + return; + } + final List decoded = jsonDecode(encoded); + _features = decoded.map((e) => HomeFeature.fromJson(e as Map)).toList(); + notifyListeners(); + } + + /// Feature 리스트 저장 + Future setFeatures(List features) async { + _features = List.from(features); + await _sharedPrefs.setString(SharedPrefKeys.kHomeFeatures, jsonEncode(features.map((e) => e.toJson()).toList())); + notifyListeners(); + } + + /// 저장된 값과 기본값을 비교하여 동기화 + /// 새로운 기능이 추가되거나 제거된 기능이 있을 때 호출 + Future synchronizeWithDefaults({List? walletList}) async { + final initialFeatures = [ + HomeFeature(homeFeatureTypeString: HomeFeatureType.recentTransaction.name, isEnabled: true), + HomeFeature(homeFeatureTypeString: HomeFeatureType.analysis.name, isEnabled: true), + ]; + + if (_features.isEmpty) { + _features.addAll(List.from(initialFeatures)); + await _saveFeatures(); + notifyListeners(); + return; + } + + // 저장된 값과 기본값이 동일한지 확인 + final isSame = const DeepCollectionEquality.unordered().equals( + _features.map((e) => e.homeFeatureTypeString).toList(), + initialFeatures.map((e) => e.homeFeatureTypeString).toList(), + ); + + if (isSame) return; + + // 기능은 추후 추가/제거 등 달라질 여지가 있기 때문에 내용을 비교해서 추가/제거 합니다. + final updatedFeatures = []; + for (final defaultFeature in initialFeatures) { + final existing = _features.firstWhereOrNull( + (e) => e.homeFeatureTypeString == defaultFeature.homeFeatureTypeString, + ); + // 기존 설정이 있으면 그대로 유지, 없으면 기본값 사용 + updatedFeatures.add(existing ?? defaultFeature); + } + + // 기본값에 없는 기능은 제거 + _features.removeWhere((e) => !initialFeatures.any((k) => k.homeFeatureTypeString == e.homeFeatureTypeString)); + + // 업데이트된 기능 추가 + _features.addAll( + updatedFeatures.where((e) => !_features.any((h) => h.homeFeatureTypeString == e.homeFeatureTypeString)), + ); + + await _saveFeatures(); + notifyListeners(); + } + + /// 내부 저장 메서드 + Future _saveFeatures() async { + await _sharedPrefs.setString(SharedPrefKeys.kHomeFeatures, jsonEncode(_features.map((e) => e.toJson()).toList())); + } + + /// 분석 설정 로드 (생성자에서 사용) + void _loadAnalysisSettingsSync() { + _analysisPeriod = _getAnalysisPeriodSync(); + _analysisPeriodRange = _getAnalysisPeriodRangeSync(); + _selectedAnalysisTransactionType = _getAnalysisTransactionTypeSync(); + } + + /// 분석 기간 로드 + int _getAnalysisPeriodSync() { + final period = _sharedPrefs.getIntOrNull(SharedPrefKeys.kAnalysisPeriod); + return period ?? 30; // 기본 기간 30일 + } + + /// 분석 기간 범위 로드 + Tuple2 _getAnalysisPeriodRangeSync() { + final start = _sharedPrefs.getString(SharedPrefKeys.kAnalysisPeriodStart); + final end = _sharedPrefs.getString(SharedPrefKeys.kAnalysisPeriodEnd); + return Tuple2(start.isEmpty ? null : DateTime.parse(start), end.isEmpty ? null : DateTime.parse(end)); + } + + /// 분석 거래 유형 로드 + AnalysisTransactionType _getAnalysisTransactionTypeSync() { + final encoded = _sharedPrefs.getString(SharedPrefKeys.kSelectedTransactionTypeIndices); + if (encoded.isEmpty) return AnalysisTransactionType.all; // [전체 = all]이 기본 + return AnalysisTransactionType.values.firstWhere( + (type) => type.name == encoded, + orElse: () => AnalysisTransactionType.all, + ); + } + + /// 분석 기간 설정 + int getAnalysisPeriod() { + return _getAnalysisPeriodSync(); + } + + Future setAnalysisPeriod(int days) async { + _analysisPeriod = days; + await _sharedPrefs.setInt(SharedPrefKeys.kAnalysisPeriod, days); + notifyListeners(); + } + + /// 분석 기간 범위 설정 + Tuple2 getAnalysisPeriodRange() { + return _getAnalysisPeriodRangeSync(); + } + + Future setAnalysisPeriodRange(DateTime start, DateTime end) async { + _analysisPeriodRange = Tuple2(start, end); + await _sharedPrefs.setString(SharedPrefKeys.kAnalysisPeriodStart, start.toIso8601String()); + await _sharedPrefs.setString(SharedPrefKeys.kAnalysisPeriodEnd, end.toIso8601String()); + notifyListeners(); + } + + /// 분석 거래 유형 설정 + AnalysisTransactionType getAnalysisTransactionType() { + return _getAnalysisTransactionTypeSync(); + } + + Future setAnalysisTransactionType(AnalysisTransactionType transactionType) async { + _selectedAnalysisTransactionType = transactionType; + await _sharedPrefs.setString(SharedPrefKeys.kSelectedTransactionTypeIndices, transactionType.name); + notifyListeners(); + } +} diff --git a/lib/providers/preference_provider.dart b/lib/providers/preferences/preference_provider.dart similarity index 71% rename from lib/providers/preference_provider.dart rename to lib/providers/preferences/preference_provider.dart index 5f7ed1a5e..c7117ecc1 100644 --- a/lib/providers/preference_provider.dart +++ b/lib/providers/preferences/preference_provider.dart @@ -4,10 +4,13 @@ import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/constants/shared_pref_keys.dart'; import 'package:coconut_wallet/enums/electrum_enums.dart'; import 'package:coconut_wallet/enums/fiat_enums.dart'; +import 'package:coconut_wallet/model/preference/home_feature.dart'; +import 'package:coconut_wallet/providers/preferences/feature_settings_provider.dart'; +import 'package:coconut_wallet/providers/view_model/home/wallet_home_view_model.dart'; import 'package:coconut_wallet/enums/utxo_enums.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; -import 'package:coconut_wallet/repository/realm/wallet_preferences_repository.dart'; import 'package:coconut_wallet/model/node/electrum_server.dart'; +import 'package:coconut_wallet/repository/realm/wallet_preferences_repository.dart'; import 'package:coconut_wallet/repository/shared_preference/shared_prefs_repository.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/utils/balance_format_util.dart'; @@ -15,11 +18,16 @@ import 'package:coconut_wallet/utils/locale_util.dart'; import 'package:coconut_wallet/utils/logger.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; class PreferenceProvider extends ChangeNotifier { final SharedPrefsRepository _sharedPrefs = SharedPrefsRepository(); final WalletPreferencesRepository _walletPreferencesRepository; + // FeatureSettingsProvider는 선택적으로 주입받을 수 있음 (Facade 패턴) + // 주입되지 않으면 내부에서 직접 관리 (하위 호환성) + FeatureSettingsProvider? _featureSettingsProvider; + /// 홈 화면 잔액 숨기기 on/off 여부 late bool _isBalanceHidden; bool get isBalanceHidden => _isBalanceHidden; @@ -27,6 +35,9 @@ class PreferenceProvider extends ChangeNotifier { late bool _isFakeBalanceActive; bool get isFakeBalanceActive => _isFakeBalanceActive; + late bool _isFiatBalanceHidden; + bool get isFiatBalanceHidden => _isFiatBalanceHidden; + /// 가짜 잔액 총량 late int? _fakeBalanceTotalBtc; int? get fakeBalanceTotalAmount => _fakeBalanceTotalBtc; @@ -75,20 +86,40 @@ class PreferenceProvider extends ChangeNotifier { late List _excludedFromTotalBalanceWalletIds; List get excludedFromTotalBalanceWalletIds => _excludedFromTotalBalanceWalletIds; - /// 마지막으로 선택한 UTXO 정렬 기준(default - 큰 금액순) + /// 홈 화면에 표시할 기능(최근 거래, 분석 ...) + // FeatureSettingsProvider로 위임 + List get homeFeatures => _featureSettingsProvider?.features ?? []; + + /// 특정 기능이 활성화되어 있는지 확인 + bool isHomeFeatureEnabled(HomeFeatureType type) { + return _featureSettingsProvider?.isEnabled(type) ?? false; + } + + // 분석 위젯 설정 (FeatureSettingsProvider로 위임) + int get analysisPeriod => _featureSettingsProvider?.analysisPeriod ?? 30; + Tuple2 get analysisPeriodRange => + _featureSettingsProvider?.analysisPeriodRange ?? const Tuple2(null, null); + AnalysisTransactionType get selectedAnalysisTransactionType => + _featureSettingsProvider?.selectedAnalysisTransactionType ?? AnalysisTransactionType.all; + late UtxoOrder _utxoSortOrder; UtxoOrder get utxoSortOrder => _utxoSortOrder; - PreferenceProvider(this._walletPreferencesRepository) { + PreferenceProvider(this._walletPreferencesRepository, {FeatureSettingsProvider? featureSettingsProvider}) + : _featureSettingsProvider = featureSettingsProvider { _fakeBalanceTotalBtc = _sharedPrefs.getIntOrNull(SharedPrefKeys.kFakeBalanceTotal); + _isFiatBalanceHidden = _sharedPrefs.getBool(SharedPrefKeys.kIsFiatBalanceHidden); _isFakeBalanceActive = _fakeBalanceTotalBtc != null; _isBalanceHidden = _sharedPrefs.getBool(SharedPrefKeys.kIsBalanceHidden); _isBtcUnit = _sharedPrefs.isContainsKey(SharedPrefKeys.kIsBtcUnit) ? _sharedPrefs.getBool(SharedPrefKeys.kIsBtcUnit) : true; _showOnlyUnusedAddresses = _sharedPrefs.getBool(SharedPrefKeys.kShowOnlyUnusedAddresses); - _walletOrder = _walletPreferencesRepository.getWalletOrder().toList(); - _favoriteWalletIds = _walletPreferencesRepository.getFavoriteWalletIds().toList(); - _excludedFromTotalBalanceWalletIds = _walletPreferencesRepository.getExcludedWalletIds().toList(); + _walletOrder = getWalletOrder(); + _favoriteWalletIds = getFavoriteWalletIds(); + _excludedFromTotalBalanceWalletIds = getExcludedFromTotalBalanceWalletIds(); + // FeatureSettingsProvider가 없으면 내부에서 생성 (하위 호환성) + // 주입된 경우에는 이미 초기화되어 있음 + _featureSettingsProvider ??= FeatureSettingsProvider(); _isReceivingTooltipDisabled = _sharedPrefs.getBool(SharedPrefKeys.kIsReceivingTooltipDisabled); _isChangeTooltipDisabled = _sharedPrefs.getBool(SharedPrefKeys.kIsChangeTooltipDisabled); _hasSeenAddRecipientCard = _sharedPrefs.getBool(SharedPrefKeys.kHasSeenAddRecipientCard); @@ -101,15 +132,15 @@ class PreferenceProvider extends ChangeNotifier { : UtxoOrder.byAmountDesc; // 통화 설정 초기화 - _initializeFiat(); - _initializeLanguageFromSystem(); + initializeFiat(); + initializeLanguageFromSystem(); WidgetsBinding.instance.addPostFrameCallback((_) { - _applyLanguageSettingSync(); + applyLanguageSettingSync(); }); } /// 통화 설정 초기화 - void _initializeFiat() { + void initializeFiat() { final fiatCode = _sharedPrefs.getString(SharedPrefKeys.kSelectedFiat); if (fiatCode.isNotEmpty) { _selectedFiat = FiatCode.values.firstWhere((fiat) => fiat.code == fiatCode, orElse: () => FiatCode.KRW); @@ -120,7 +151,7 @@ class PreferenceProvider extends ChangeNotifier { } /// OS 설정에 따라 언어 설정 초기화 - void _initializeLanguageFromSystem() { + void initializeLanguageFromSystem() { bool changed = false; if (_sharedPrefs.isContainsKey(SharedPrefKeys.kLanguage)) { _language = _sharedPrefs.getString(SharedPrefKeys.kLanguage); @@ -130,14 +161,14 @@ class PreferenceProvider extends ChangeNotifier { changed = true; } - _applyLanguageSettingSync(); + applyLanguageSettingSync(); if (changed) { notifyListeners(); } } /// 언어 설정 적용 (동기 버전) - void _applyLanguageSettingSync() { + void applyLanguageSettingSync() { try { Logger.log('Applying language setting: $_language'); if (isKorean) { @@ -160,7 +191,7 @@ class PreferenceProvider extends ChangeNotifier { } /// 언어 설정 적용 - Future _applyLanguageSetting() async { + Future applyLanguageSetting() async { try { if (isKorean) { await LocaleSettings.setLocale(AppLocale.kr); @@ -186,6 +217,14 @@ class PreferenceProvider extends ChangeNotifier { notifyListeners(); } + /// 홈 화면 법정화폐 잔액 숨기기 + Future changeIsFiatBalanceHidden(bool isOn) async { + _isFiatBalanceHidden = isOn; + await _sharedPrefs.setBool(SharedPrefKeys.kIsFiatBalanceHidden, isOn); + + notifyListeners(); + } + /// 가짜 잔액 활성화 상태 변경 Future toggleFakeBalanceActivation(bool isActive) async { _isFakeBalanceActive = isActive; @@ -229,7 +268,7 @@ class PreferenceProvider extends ChangeNotifier { await _sharedPrefs.setString(SharedPrefKeys.kLanguage, languageCode); // 언어 설정 적용 - await _applyLanguageSetting(); + await applyLanguageSetting(); // 언어 설정 후 상태 업데이트 notifyListeners(); @@ -330,42 +369,126 @@ class PreferenceProvider extends ChangeNotifier { await _sharedPrefs.setString(SharedPrefKeys.kFakeBalanceMap, encoded); } + /// 지갑 순서 불러오기 + List getWalletOrder() { + final encoded = _walletPreferencesRepository.getWalletOrder().toList(); + if (encoded.isEmpty) return []; + return encoded; + } + /// 지갑 순서 설정 Future setWalletOrder(List walletOrder) async { _walletOrder = walletOrder; - await _walletPreferencesRepository.setWalletOrder(walletOrder); + await _sharedPrefs.setString(SharedPrefKeys.kWalletOrder, jsonEncode(walletOrder)); notifyListeners(); } + /// 지갑 순서 단일 제거 Future removeWalletOrder(int walletId) async { _walletOrder.remove(walletId); - await _walletPreferencesRepository.setWalletOrder(_walletOrder); + await setWalletOrder(_walletOrder); notifyListeners(); } - /// 지갑 즐겨찾기 설정 - Future setFavoriteWalletIds(List ids) async { - _favoriteWalletIds = ids; - await _walletPreferencesRepository.setFavoriteWalletIds(ids); + /// 지갑 즐겨찾기 목록 불러오기 + List getFavoriteWalletIds() { + final encoded = _sharedPrefs.getString(SharedPrefKeys.kFavoriteWalletIds); + if (encoded.isEmpty) return []; + final List decoded = jsonDecode(encoded); + return decoded.cast(); + } + + /// 지갑 즐겨찾기 목록 설정 + Future setFavoriteWalletIds(List favoriteWalletIds) async { + _favoriteWalletIds = favoriteWalletIds; + await _sharedPrefs.setString(SharedPrefKeys.kFavoriteWalletIds, jsonEncode(favoriteWalletIds)); notifyListeners(); } + /// 지갑 즐겨찾기 단일 제거 Future removeFavoriteWalletId(int walletId) async { _favoriteWalletIds.remove(walletId); - await _walletPreferencesRepository.setFavoriteWalletIds(_favoriteWalletIds); + await setFavoriteWalletIds(_favoriteWalletIds); notifyListeners(); } + /// 총 잔액에서 제외할 지갑 목록 불러오기 + List getExcludedFromTotalBalanceWalletIds() { + final encoded = _sharedPrefs.getString(SharedPrefKeys.kExcludedFromTotalBalanceWalletIds); + if (encoded.isEmpty) return []; + final List decoded = jsonDecode(encoded); + return decoded.cast(); + } + /// 총 잔액에서 제외할 지갑 설정 Future setExcludedFromTotalBalanceWalletIds(List ids) async { _excludedFromTotalBalanceWalletIds = ids; - await _walletPreferencesRepository.setExcludedWalletIds(ids); + await _sharedPrefs.setString(SharedPrefKeys.kExcludedFromTotalBalanceWalletIds, jsonEncode(ids)); notifyListeners(); } + /// 총 잔액에서 제외할 지갑 단일 제거 Future removeExcludedFromTotalBalanceWalletId(int walletId) async { _excludedFromTotalBalanceWalletIds.remove(walletId); - await _walletPreferencesRepository.setExcludedWalletIds(_excludedFromTotalBalanceWalletIds); + await setExcludedFromTotalBalanceWalletIds(_excludedFromTotalBalanceWalletIds); + notifyListeners(); + } + + /// 홈 화면에 표시할 기능 (FeatureSettingsProvider로 위임) + List getHomeFeatures() { + return _featureSettingsProvider?.features ?? []; + } + + Future setHomeFeautres(List features) async { + // FeatureSettingsProvider로 위임 + await _featureSettingsProvider?.setFeatures(features); + notifyListeners(); + } + + Future setWalletPreferences(List walletItemList) async { + var walletOrder = _walletOrder; + var favoriteWalletIds = _favoriteWalletIds; + + if (walletOrder.isEmpty) { + walletOrder = List.from(walletItemList.map((w) => w.id)); + await setWalletOrder(walletOrder); + } + if (favoriteWalletIds.isEmpty) { + favoriteWalletIds = List.from(walletItemList.take(5).map((w) => w.id)); + await setFavoriteWalletIds(favoriteWalletIds); + } + + // FeatureSettingsProvider의 동기화 메서드 사용 + await _featureSettingsProvider?.synchronizeWithDefaults(walletList: walletItemList); + + notifyListeners(); + } + + /// 분석 설정 (FeatureSettingsProvider로 위임) + Tuple2 getAnalysisPeriodRange() { + return _featureSettingsProvider?.getAnalysisPeriodRange() ?? const Tuple2(null, null); + } + + Future setAnalysisPeriodRange(DateTime start, DateTime end) async { + await _featureSettingsProvider?.setAnalysisPeriodRange(start, end); + notifyListeners(); + } + + int getAnalysisPeriod() { + return _featureSettingsProvider?.getAnalysisPeriod() ?? 30; + } + + Future setAnalysisPeriod(int days) async { + await _featureSettingsProvider?.setAnalysisPeriod(days); + notifyListeners(); + } + + AnalysisTransactionType getAnalysisTransactionType() { + return _featureSettingsProvider?.getAnalysisTransactionType() ?? AnalysisTransactionType.all; + } + + Future setAnalysisTransactionType(AnalysisTransactionType transactionType) async { + await _featureSettingsProvider?.setAnalysisTransactionType(transactionType); notifyListeners(); } @@ -377,7 +500,7 @@ class PreferenceProvider extends ChangeNotifier { } /// 커스텀 일렉트럼 서버 파라미터 검증 - void _validateCustomElectrumServerParams(String host, int port, bool ssl) { + void validateCustomElectrumServerParams(String host, int port, bool ssl) { if (host.trim().isEmpty) { throw ArgumentError('Host cannot be empty'); } @@ -394,7 +517,7 @@ class PreferenceProvider extends ChangeNotifier { /// 커스텀 일렉트럼 서버 설정 Future setCustomElectrumServer(String host, int port, bool ssl) async { - _validateCustomElectrumServerParams(host, port, ssl); + validateCustomElectrumServerParams(host, port, ssl); await _sharedPrefs.setString(SharedPrefKeys.kElectrumServerName, 'CUSTOM'); await _sharedPrefs.setString(SharedPrefKeys.kCustomElectrumHost, host); await _sharedPrefs.setInt(SharedPrefKeys.kCustomElectrumPort, port); diff --git a/lib/providers/price_provider.dart b/lib/providers/price_provider.dart index 97d58ba0b..cf865cfce 100644 --- a/lib/providers/price_provider.dart +++ b/lib/providers/price_provider.dart @@ -1,7 +1,7 @@ import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/extensions/int_extensions.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/services/web_socket_service.dart'; import 'package:coconut_wallet/utils/fiat_util.dart'; import 'package:coconut_wallet/utils/logger.dart'; diff --git a/lib/providers/view_model/home/wallet_add_input_view_model.dart b/lib/providers/view_model/home/wallet_add_input_view_model.dart index 24561912c..da310b9b7 100644 --- a/lib/providers/view_model/home/wallet_add_input_view_model.dart +++ b/lib/providers/view_model/home/wallet_add_input_view_model.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/enums/wallet_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/services/wallet_add_service.dart'; import 'package:coconut_wallet/utils/descriptor_util.dart'; diff --git a/lib/providers/view_model/home/wallet_add_scanner_view_model.dart b/lib/providers/view_model/home/wallet_add_scanner_view_model.dart index 660a4e392..f79eb350f 100644 --- a/lib/providers/view_model/home/wallet_add_scanner_view_model.dart +++ b/lib/providers/view_model/home/wallet_add_scanner_view_model.dart @@ -1,6 +1,6 @@ import 'package:coconut_wallet/enums/wallet_enums.dart'; import 'package:coconut_wallet/model/wallet/watch_only_wallet.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/services/wallet_add_service.dart'; import 'package:coconut_wallet/utils/file_logger.dart'; diff --git a/lib/providers/view_model/home/wallet_home_edit_view_model.dart b/lib/providers/view_model/home/wallet_home_edit_view_model.dart new file mode 100644 index 000000000..7118cb0c7 --- /dev/null +++ b/lib/providers/view_model/home/wallet_home_edit_view_model.dart @@ -0,0 +1,293 @@ +import 'dart:math'; + +import 'package:coconut_wallet/model/preference/home_feature.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; +import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/screens/home/wallet_home_edit_bottom_sheet.dart'; +import 'package:coconut_wallet/utils/balance_format_util.dart'; +import 'package:flutter/material.dart'; + +class WalletHomeEditViewModel extends ChangeNotifier { + WalletProvider _walletProvider; + late final PreferenceProvider _preferenceProvider; + late bool _isBalanceHidden; + late bool _isFiatBalanceHidden; + late bool _isFakeBalanceActive; + + late int minimumSatoshi; + final int maximumAmount = 21000000; + final int maxInputLength = 17; // 21000000.00000000 + + Map _fakeBalanceMap = {}; + int? _fakeBalanceTotalAmount; + double? _fakeBalanceTotalBtc; + String? _fakeBalanceText; + FakeBalanceInputError _inputError = FakeBalanceInputError.none; + + // temp datas + late List _tempHomeFeatures; + late bool _tempIsBalanceHidden; + late bool _tempIsFiatBalanceHidden; + late bool _tempIsFakeBalanceActive; + late double? _tempFakeBalanceTotalBtc; + late int? _tempFakeBalanceTotalAmount; + + WalletHomeEditViewModel(this._walletProvider, this._preferenceProvider) { + // _walletBalance = _walletProvider + // .fetchWalletBalanceMap() + // .map((key, balance) => MapEntry(key, AnimatedBalanceData(balance.total, balance.total))); + minimumSatoshi = _walletProvider.walletItemList.length; + _isBalanceHidden = _preferenceProvider.isBalanceHidden; + _isFiatBalanceHidden = _preferenceProvider.isFiatBalanceHidden; + _isFakeBalanceActive = _preferenceProvider.isFakeBalanceActive; + _fakeBalanceTotalAmount = _preferenceProvider.fakeBalanceTotalAmount; + _fakeBalanceMap = _preferenceProvider.getFakeBalanceMap(); + + _fakeBalanceTotalBtc = + _preferenceProvider.fakeBalanceTotalAmount != null + ? UnitUtil.convertSatoshiToBitcoin(_preferenceProvider.fakeBalanceTotalAmount!) + : null; + + if (_fakeBalanceTotalBtc != null) { + if (_fakeBalanceTotalBtc == 0) { + // 0일 때 + _fakeBalanceText = '0'; + } else if (_fakeBalanceTotalBtc! % 1 == 0) { + // 정수일 때 + _fakeBalanceText = _fakeBalanceTotalBtc.toString().split('.')[0]; + } else { + _fakeBalanceText = _fakeBalanceTotalBtc.toString(); + } + } + + _tempHomeFeatures = + _preferenceProvider.homeFeatures + .map( + (feature) => + HomeFeature(homeFeatureTypeString: feature.homeFeatureTypeString, isEnabled: feature.isEnabled), + ) + .toList(); + _tempIsBalanceHidden = isBalanceHidden; + _tempIsFiatBalanceHidden = isFiatBalanceHidden; + _tempIsFakeBalanceActive = isFakeBalanceActive; + _tempFakeBalanceTotalBtc = fakeBalanceTotalBtc; + _tempFakeBalanceTotalAmount = fakeBalanceTotalAmount; + } + + bool get isBalanceHidden => _isBalanceHidden; + bool get isFiatBalanceHidden => _isFiatBalanceHidden; + bool get isFakeBalanceActive => _isFakeBalanceActive; + int? get fakeBalanceTotalAmount => _fakeBalanceTotalAmount; + double? get fakeBalanceTotalBtc => _fakeBalanceTotalBtc; + String? get fakeBalanceText => _fakeBalanceText; + Map get fakeBalanceMap => _fakeBalanceMap; + int get walletItemLength => _walletProvider.walletItemList.length; + List get homeFeatures => _preferenceProvider.homeFeatures; + FakeBalanceInputError get inputError => _inputError; + + List get tempHomeFeatures => _tempHomeFeatures; + bool get tempIsBalanceHidden => _tempIsBalanceHidden; + bool get tempIsFiatBalanceHidden => _tempIsFiatBalanceHidden; + bool get tempIsFakeBalanceActive => _tempIsFakeBalanceActive; + double? get tempFakeBalanceTotalBtc => _tempFakeBalanceTotalBtc; + int? get tempFakeBalanceTotalAmount => _tempFakeBalanceTotalAmount; + + void onWalletProviderUpdated(WalletProvider walletProvider) { + _walletProvider = walletProvider; + notifyListeners(); + } + + void onPreferenceProviderUpdated() { + /// 잔액 숨기기 변동 체크 + if (_isBalanceHidden != _preferenceProvider.isBalanceHidden) { + setIsBalanceHidden(_preferenceProvider.isBalanceHidden); + } + + /// 가짜 잔액 총량 변동 체크 (on/off 판별) + if (_fakeBalanceTotalAmount != _preferenceProvider.fakeBalanceTotalAmount) { + _setFakeBalanceTotalAmount(_preferenceProvider.fakeBalanceTotalAmount); + _setFakeBlanceMap(_preferenceProvider.getFakeBalanceMap()); + } + + /// 잔액 숨기기 변동 체크 + if (_isFiatBalanceHidden != _preferenceProvider.isFiatBalanceHidden) { + setIsFiatBalanceHidden(_preferenceProvider.isFiatBalanceHidden); + } + notifyListeners(); + } + + void setIsBalanceHidden(bool value) { + _preferenceProvider.changeIsBalanceHidden(value); + _isBalanceHidden = value; + if (!value) clearFakeBlanceTotalAmount(); + notifyListeners(); + } + + void setTempIsBalanceHidden(bool value) { + _tempIsBalanceHidden = value; + if (!value) { + _tempFakeBalanceTotalAmount = null; + // _tempIsFakeBalanceActive = false; + } + notifyListeners(); + } + + void setIsFiatBalanceHidden(bool value) { + _preferenceProvider.changeIsFiatBalanceHidden(value); + _isFiatBalanceHidden = value; + notifyListeners(); + } + + void setTempIsFiatBalanceHidden(bool value) { + _tempIsFiatBalanceHidden = value; + notifyListeners(); + } + + void clearFakeBlanceTotalAmount() { + _preferenceProvider.clearFakeBalanceTotalAmount(); + _preferenceProvider.toggleFakeBalanceActivation(false); + _isFakeBalanceActive = false; + notifyListeners(); + } + + void setIsFakeBalanceActive(bool value) { + _preferenceProvider.toggleFakeBalanceActivation(value); + _isFakeBalanceActive = value; + notifyListeners(); + } + + void setTempFakeBalanceActive(bool value) { + _tempIsFakeBalanceActive = value; + notifyListeners(); + } + + void _setFakeBalanceTotalAmount(int? value) { + _fakeBalanceTotalAmount = value; + notifyListeners(); + } + + void setTempFakeBalanceTotalAmount(int? value) { + _tempFakeBalanceTotalAmount = value; + notifyListeners(); + } + + void _setFakeBlanceMap(Map value) { + _fakeBalanceMap = value; + notifyListeners(); + } + + void setFakeBalanceTotalBtc(double? value) { + _fakeBalanceTotalBtc = value; + notifyListeners(); + } + + void setTempFakeBalanceTotalBtc(double? value) { + _tempFakeBalanceTotalBtc = value; + notifyListeners(); + } + + void setInputError(FakeBalanceInputError error) { + _inputError = error; + notifyListeners(); + } + + void toggleTempHomeFeatureEnabled(String homeFeatureTypeString) { + final index = _tempHomeFeatures.indexWhere((element) => element.homeFeatureTypeString == homeFeatureTypeString); + if (index != -1) { + final feature = _tempHomeFeatures[index]; + _tempHomeFeatures[index] = HomeFeature( + homeFeatureTypeString: feature.homeFeatureTypeString, + isEnabled: !feature.isEnabled, + ); + notifyListeners(); + } + } + + int? getFakeTotalBalance() { + return _fakeBalanceTotalAmount; + } + + Future onComplete() async { + setIsBalanceHidden(_tempIsBalanceHidden); + setIsFiatBalanceHidden(_tempIsFiatBalanceHidden); + _setHomeFeatureEnabled(); + await _setFakeBalance(); + } + + Future _setHomeFeatureEnabled() async { + await _preferenceProvider.setHomeFeautres(_tempHomeFeatures); + } + + Future _setFakeBalance() async { + final wallets = _walletProvider.walletItemList; + if (!_tempIsFakeBalanceActive) { + await _preferenceProvider.toggleFakeBalanceActivation(false); + + return; + } + + if (_tempFakeBalanceTotalBtc == null || wallets.isEmpty) return; + + if (_tempFakeBalanceTotalBtc == 0) { + await _preferenceProvider.setFakeBalanceTotalAmount(0); + + final Map fakeBalanceMap = {}; + for (int i = 0; i < wallets.length; i++) { + final walletId = wallets[i].id; + + fakeBalanceMap[walletId] = 0; + debugPrint('[Wallet $i]Fake Balance: ${fakeBalanceMap[i]} BTC'); + } + await _preferenceProvider.setFakeBalanceMap(fakeBalanceMap); + await _preferenceProvider.toggleFakeBalanceActivation(_tempIsFakeBalanceActive); + return; + } + + final walletCount = wallets.length; + + if (!_tempFakeBalanceTotalBtc.toString().contains('.')) { + // input값이 정수 일 때 sats로 환산 + _tempFakeBalanceTotalBtc = _tempFakeBalanceTotalBtc! * 100000000; + } else { + // input이 소수일 때 소수점 이하 8자리로 맞춘 후 정수로 변환 + final fixedString = _tempFakeBalanceTotalBtc!.toStringAsFixed(8).replaceAll('.', ''); + _tempFakeBalanceTotalBtc = double.parse(fixedString); + } + + if (_tempFakeBalanceTotalBtc! < walletCount) return; // 최소 1사토시씩 못 주면 리턴 + + final random = Random(); + // 1. 각 지갑에 최소 1사토시 할당 + // 2. 남은 사토시를 랜덤 가중치로 분배 + final List weights = List.generate(walletCount, (_) => random.nextInt(100) + 1); // 1~100 + final int weightSum = weights.reduce((a, b) => a + b); + final int remainingSats = (_tempFakeBalanceTotalBtc! - walletCount).toInt(); + final List splits = []; + + for (int i = 0; i < walletCount; i++) { + final int share = (remainingSats * weights[i] / weightSum).floor(); + splits.add(1 + share); // 최소 1 사토시 보장 + } + + // 보정: 분할의 총합이 totalSats보다 작을 수 있으므로 마지막 지갑에 부족분 추가 + final int diff = (_tempFakeBalanceTotalBtc! - splits.reduce((a, b) => a + b)).toInt(); + splits[splits.length - 1] += diff; + + final Map fakeBalanceMap = {}; + + if (_preferenceProvider.isFakeBalanceActive != _tempIsFakeBalanceActive) { + await _preferenceProvider.toggleFakeBalanceActivation(_tempIsFakeBalanceActive); + } + + await _preferenceProvider.setFakeBalanceTotalAmount(_tempFakeBalanceTotalBtc!.toInt()); + + for (int i = 0; i < splits.length; i++) { + final walletId = wallets[i].id; + final fakeBalance = splits[i]; + fakeBalanceMap[walletId] = fakeBalance; + debugPrint('[Wallet $i]Fake Balance:::::: ${splits[i]} Sats'); + } + + await _preferenceProvider.setFakeBalanceMap(fakeBalanceMap); + } +} diff --git a/lib/providers/view_model/home/wallet_home_view_model.dart b/lib/providers/view_model/home/wallet_home_view_model.dart index 60f33a156..d6fc2fb50 100644 --- a/lib/providers/view_model/home/wallet_home_view_model.dart +++ b/lib/providers/view_model/home/wallet_home_view_model.dart @@ -1,31 +1,42 @@ import 'dart:async'; +import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/enums/network_enums.dart'; +import 'package:coconut_wallet/localization/strings.g.dart'; +import 'package:coconut_wallet/model/preference/home_feature.dart'; import 'package:coconut_wallet/model/wallet/balance.dart'; +import 'package:coconut_wallet/model/wallet/transaction_record.dart'; +import 'package:coconut_wallet/model/wallet/wallet_address.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/visibility_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/services/app_review_service.dart'; +import 'package:coconut_wallet/services/model/response/block_timestamp.dart'; import 'package:coconut_wallet/utils/logger.dart'; -import 'package:coconut_wallet/utils/vibration_util.dart'; +import 'package:coconut_wallet/utils/transaction_util.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:tuple/tuple.dart'; typedef AnimatedBalanceDataGetter = AnimatedBalanceData Function(int id); typedef BalanceGetter = int Function(int id); typedef FakeBalanceGetter = int? Function(int id); +const kRecenctTransactionDays = 1; + class WalletHomeViewModel extends ChangeNotifier { late final VisibilityProvider _visibilityProvider; WalletProvider _walletProvider; final NodeProvider _nodeProvider; final Stream _syncNodeStateStream; + final Stream _currentBlockStream; late final PreferenceProvider _preferenceProvider; late bool _isTermsShortcutVisible; late bool _isBalanceHidden; + late bool _isFiatBalanceHidden; late final bool _isReviewScreenVisible; late final ConnectivityProvider _connectivityProvider; late bool? _isNetworkOn; @@ -36,34 +47,78 @@ class WalletHomeViewModel extends ChangeNotifier { bool _isEmptyFavoriteWallet = false; // 즐겨찾기 설정된 지갑이 없는지 여부 NodeSyncState _nodeSyncState = NodeSyncState.syncing; StreamSubscription? _syncNodeStateSubscription; + StreamSubscription? _currentBlockSubscription; List _favoriteWallets = []; + late int _analysisPeriod; + int get analysisPeriod => _analysisPeriod; + late AnalysisTransactionType _selectedAnalysisTransactionType; + AnalysisTransactionType get selectedAnalysisTransactionType => _selectedAnalysisTransactionType; + String get selectedAnalysisTransactionTypeName { + switch (_selectedAnalysisTransactionType) { + case AnalysisTransactionType.onlyReceived: + return t.receive; + case AnalysisTransactionType.onlySent: + return t.send; + case AnalysisTransactionType.all: + return t.all; + } + } late List _excludedFromTotalBalanceWalletIds = []; List get excludedFromTotalBalanceWalletIds => _excludedFromTotalBalanceWalletIds; + late WalletAddress _receiveAddress; + + // 현재 블록 높이 상태 + BlockTimestamp? _currentBlock; + + BlockTimestamp? get currentBlock => _currentBlock; + int? get currentBlockHeight => _currentBlock?.height; + + bool _isFetchingLatestTx = false; + bool get isFetchingLatestTx => _isFetchingLatestTx; + + bool _isLatestTxAnalysisRunning = false; + bool get isLatestTxAnalysisRunning => _isLatestTxAnalysisRunning; + + Map> _recentTransactions = {}; + Map> get recentTransactions => _recentTransactions; + + RecentTransactionAnalysis? _recentTransactionAnalysis; + RecentTransactionAnalysis? get recentTransactionAnalysis => _recentTransactionAnalysis; + + bool _isBtcUnit = false; + bool get isBtcUnit => _isBtcUnit; + WalletHomeViewModel( this._walletProvider, this._preferenceProvider, this._visibilityProvider, this._connectivityProvider, this._nodeProvider, - ) : _syncNodeStateStream = _nodeProvider.syncStateStream { + ) : _syncNodeStateStream = _nodeProvider.syncStateStream, + _currentBlockStream = _nodeProvider.currentBlockStream { _isTermsShortcutVisible = _visibilityProvider.visibleTermsShortcut; _isReviewScreenVisible = AppReviewService.shouldShowReviewScreen(); _isNetworkOn = _connectivityProvider.isNetworkOn; _syncNodeStateSubscription = _syncNodeStateStream.listen(_handleNodeSyncState); + _currentBlockSubscription = _currentBlockStream.listen(_handleCurrentBlockUpdate); + _walletBalance = _walletProvider.fetchWalletBalanceMap().map( (key, balance) => MapEntry(key, AnimatedBalanceData(balance.total, balance.total)), ); - _walletProvider.walletLoadStateNotifier.addListener(updateWalletBalances); + _walletProvider.walletLoadStateNotifier.addListener(updateWalletBalancesAndRecentTxs); // NodeProvider의 변경사항 listening 추가 _nodeProvider.addListener(_onNodeProviderChanged); _isBalanceHidden = _preferenceProvider.isBalanceHidden; + _isFiatBalanceHidden = _preferenceProvider.isFiatBalanceHidden; _fakeBalanceTotalAmount = _preferenceProvider.fakeBalanceTotalAmount; _fakeBalanceMap = _preferenceProvider.getFakeBalanceMap(); _excludedFromTotalBalanceWalletIds = _preferenceProvider.excludedFromTotalBalanceWalletIds; + _analysisPeriod = _preferenceProvider.analysisPeriod; + _selectedAnalysisTransactionType = _preferenceProvider.selectedAnalysisTransactionType; } void _onNodeProviderChanged() { @@ -73,6 +128,7 @@ class WalletHomeViewModel extends ChangeNotifier { bool get isEmptyFavoriteWallet => _isEmptyFavoriteWallet; bool get isBalanceHidden => _isBalanceHidden; + bool get isFiatBalanceHidden => _isFiatBalanceHidden; bool get isReviewScreenVisible => _isReviewScreenVisible; bool get isTermsShortcutVisible => _isTermsShortcutVisible; bool get shouldShowLoadingIndicator => !_isFirstLoaded && _nodeSyncState == NodeSyncState.syncing; @@ -91,6 +147,7 @@ class WalletHomeViewModel extends ChangeNotifier { } List get favoriteWallets => _favoriteWallets; + List get homeFeatures => _preferenceProvider.homeFeatures; bool? get isNetworkOn => _isNetworkOn; int? get fakeBalanceTotalAmount => _fakeBalanceTotalAmount; @@ -126,7 +183,7 @@ class WalletHomeViewModel extends ChangeNotifier { _isFirstLoaded = true; // vibrateLight(); 네트워크 동기화 완료시 진동 - 제거 요청됨 } - updateWalletBalances(); + updateWalletBalancesAndRecentTxs(); } else if (syncState == NodeSyncState.failed) { // vibrateLightDouble(); 네트워크 동기화 실패시 진동 - 제거 요청됨 } @@ -141,6 +198,25 @@ class WalletHomeViewModel extends ChangeNotifier { } } + void _handleCurrentBlockUpdate(BlockTimestamp? currentBlock) { + _currentBlock = currentBlock; + Logger.log('WalletHomeViewModel: 현재 블록 높이 업데이트 - ${currentBlock?.height}, 동기화 상태: $_nodeSyncState'); + + // 동기화가 완료되었고 블록 높이가 있으면 트랜잭션 조회 + if (_nodeSyncState == NodeSyncState.completed && currentBlock?.height != null) { + Logger.log('WalletHomeViewModel: 동기화 완료 후 블록 높이 업데이트 - 트랜잭션 조회 실행 (블록 높이: ${currentBlock!.height})'); + getPendingAndRecentDaysTransactions(currentBlock.height, kRecenctTransactionDays); + + getRecentTransactionAnalysis(_analysisPeriod); + } else { + Logger.log( + 'WalletHomeViewModel: _handleCurrentBlockUpdate - 트랜잭션 조회 건너뜀 (동기화 상태: $_nodeSyncState, 블록 높이: ${currentBlock?.height})', + ); + } + + notifyListeners(); + } + void hideTermsShortcut() { _isTermsShortcutVisible = false; _visibilityProvider.hideTermsShortcut(); @@ -148,7 +224,7 @@ class WalletHomeViewModel extends ChangeNotifier { } Future onRefresh() async { - updateWalletBalances(); + updateWalletBalancesAndRecentTxs(); if (networkStatus != NetworkStatus.connectionFailed) { return; @@ -157,9 +233,15 @@ class WalletHomeViewModel extends ChangeNotifier { _nodeProvider.reconnect(); } - void updateWalletBalances() { + Future updateWalletBalancesAndRecentTxs() async { final updatedWalletBalance = _updateBalanceMap(_walletProvider.fetchWalletBalanceMap()); _walletBalance = updatedWalletBalance; + if (currentBlock?.height != null) { + getPendingAndRecentDaysTransactions(currentBlock!.height, kRecenctTransactionDays); + } + if (currentBlock?.height != null) { + getRecentTransactionAnalysis(_analysisPeriod); + } notifyListeners(); } @@ -181,6 +263,11 @@ class WalletHomeViewModel extends ChangeNotifier { setIsBalanceHidden(_preferenceProvider.isBalanceHidden); } + /// 법정화폐잔액숨기기 변동 체크 + if (_isFiatBalanceHidden != _preferenceProvider.isFiatBalanceHidden) { + setIsFiatBalanceHidden(_preferenceProvider.isFiatBalanceHidden); + } + /// 가짜 잔액 총량 변동 체크 (on/off 판별) if (_fakeBalanceTotalAmount != _preferenceProvider.fakeBalanceTotalAmount) { _setFakeBlancTotalAmount(_preferenceProvider.fakeBalanceTotalAmount); @@ -199,6 +286,9 @@ class WalletHomeViewModel extends ChangeNotifier { loadFavoriteWallets(); } + /// 홈 기능 설정(HomeFeatures) 변동 체크 + // HomeFeatureProvider가 직접 관리하므로 별도 로드 불필요 + /// 총 잔액에서 제외할 지갑 목록 변경 체크 if (!const SetEquality().equals( _excludedFromTotalBalanceWalletIds.toSet(), @@ -206,6 +296,15 @@ class WalletHomeViewModel extends ChangeNotifier { )) { _excludedFromTotalBalanceWalletIds = _preferenceProvider.excludedFromTotalBalanceWalletIds; } + + if (_analysisPeriod != _preferenceProvider.analysisPeriod) { + _analysisPeriod = _preferenceProvider.analysisPeriod; + } + + if (_preferenceProvider.isBtcUnit != _isBtcUnit) { + _isBtcUnit = _preferenceProvider.isBtcUnit; + getRecentTransactionAnalysis(_analysisPeriod); + } notifyListeners(); } @@ -215,6 +314,12 @@ class WalletHomeViewModel extends ChangeNotifier { notifyListeners(); } + void setIsFiatBalanceHidden(bool value) { + _preferenceProvider.changeIsFiatBalanceHidden(value); + _isFiatBalanceHidden = value; + notifyListeners(); + } + void _setFakeBlancTotalAmount(int? value) { _fakeBalanceTotalAmount = value; notifyListeners(); @@ -304,10 +409,226 @@ class WalletHomeViewModel extends ChangeNotifier { notifyListeners(); } + void setReceiveAddress(int walletId) { + _receiveAddress = _walletProvider.getReceiveAddress(walletId); + Logger.log('--> 리시브주소: ${_receiveAddress.address}'); + } + + // 필요한 경우 호출 + void getPendingAndRecentDaysTransactions(int? blockHeight, int days) { + if (blockHeight == null || _isFetchingLatestTx) return; + + if (!_preferenceProvider.isHomeFeatureEnabled(HomeFeatureType.recentTransaction)) return; + // 홈 화면에 표시한 지갑 목록 아이디 + _isFetchingLatestTx = true; + + final walletIds = walletItemList.map((w) => w.id).toList(); + final transactions = _walletProvider.getPendingAndDaysAgoTransactions(walletIds, blockHeight, days); + _recentTransactions = transactions; + + // recentTransactions 로그 출력 + for (var entry in _recentTransactions.entries) { + Logger.log('WalletHomeViewModel: 지갑 ID ${entry.key} - 트랜잭션 개수: ${entry.value.length}'); + // 개별 트랜잭션의 해시와 value 출력 + for (var tx in entry.value) { + Logger.log( + '\tTxHash: ${tx.transactionHash}, Type: ${tx.transactionType.name}, Amount: ${tx.amount}, Status: ${TransactionUtil.getStatus(tx)}', + ); + } + } + + _isFetchingLatestTx = false; + notifyListeners(); + } + + // 필요한 경우 호출 + void getRecentTransactionAnalysis(int days) { + // if (_isLatestTxAnalysisRunning) return; + + _isLatestTxAnalysisRunning = true; + debugPrint('DEBUG11 - getRecentTransactionAnalysis'); + // 최근 n days 동안의 트랜잭션을 분석한 결과 + // 조회 기간 내 트랜잭션 개수 + // 조회 기간 내 트랜잭션의 받은 총 금액 + // 조회 기간 내 트랜잭션의 보낸 총 금액 + final walletIds = walletItemList.map((w) => w.id).toList(); + debugPrint('DEBUG11 - currentBlock?.height: ${currentBlock?.height}'); + if (currentBlock?.height == null) return; + final currentBlockHeight = currentBlock!.height; + debugPrint('DEBUG11 - currentBlockHeight: $currentBlockHeight'); + debugPrint('DEBUG11 - analysisPeriod: $_analysisPeriod'); + debugPrint('analysisPeriodRange.item1: ${_preferenceProvider.analysisPeriodRange.item1}'); + debugPrint('analysisPeriodRange.item2: ${_preferenceProvider.analysisPeriodRange.item2}'); + debugPrint('days: $days'); + final transactions = + _analysisPeriod == 0 && + _preferenceProvider.analysisPeriodRange.item1 != null && + _preferenceProvider.analysisPeriodRange.item2 != null + ? _walletProvider.getConfirmedTransactionRecordListWithinDateRange( + walletIds, + currentBlockHeight, + Tuple2( + _preferenceProvider.analysisPeriodRange.item1!, + _preferenceProvider.analysisPeriodRange.item2!, + ), + ) + : _walletProvider.getConfirmedTransactionRecordListWithin(walletIds, currentBlockHeight, days); + + final receivedTxs = transactions.where((t) => t.transactionType == TransactionType.received).toList(); + final sentTxs = transactions.where((t) => t.transactionType == TransactionType.sent).toList(); + final selfTxs = transactions.where((t) => t.transactionType == TransactionType.self).toList(); + + final receivedAmount = receivedTxs.fold(0, (sum, t) => sum + t.amount); + final sentAmount = sentTxs.fold(0, (sum, t) => sum + t.amount); + final selfAmount = selfTxs.fold(0, (sum, t) => sum + t.amount); + + final totalAmount = receivedAmount + sentAmount + selfAmount; + final totalTransactionCount = receivedTxs.length + sentTxs.length + selfTxs.length; + + Logger.log('WalletHomeViewModel: 최근 $days일 동안의 트랜잭션 분석 결과'); + Logger.log('WalletHomeViewModel: ${totalAmount.abs()}${totalAmount > 0 ? ' 증가했어요' : ' 감소했어요'}'); + // UTC 기간 출력 : 30일 전 - 오늘 yy.mm.dd 형식으로 출력 + final startDate = + _analysisPeriod == 0 && + _preferenceProvider.analysisPeriodRange.item1 != null && + _preferenceProvider.analysisPeriodRange.item2 != null + ? _preferenceProvider.analysisPeriodRange.item1! + : DateTime.now().subtract(Duration(days: days)).toUtc(); + final endDate = + _analysisPeriod == 0 && + _preferenceProvider.analysisPeriodRange.item1 != null && + _preferenceProvider.analysisPeriodRange.item2 != null + ? _preferenceProvider.analysisPeriodRange.item2! + : DateTime.now().toUtc(); + + Logger.log( + 'WalletHomeViewModel: 기간: ${startDate.toLocal().toString().split(' ')[0]} ~ ${endDate.toLocal().toString().split(' ')[0]} | 트랜잭션 $totalTransactionCount 회', + ); + Logger.log('WalletHomeViewModel: ⬇️ Received: ${receivedTxs.length}회 $receivedAmount '); + Logger.log('WalletHomeViewModel: ⬆️ Sent: ${sentTxs.length}회 $sentAmount '); + Logger.log('WalletHomeViewModel: 🔄 Self: ${selfTxs.length}회 $selfAmount '); + + _recentTransactionAnalysis = RecentTransactionAnalysis( + startDate: startDate, + endDate: endDate, + receivedTxs: receivedTxs, + sentTxs: sentTxs, + selfTxs: selfTxs, + totalAmount: totalAmount, + receivedAmount: receivedAmount, + sentAmount: sentAmount, + selfAmount: selfAmount, + days: days, + isBtcUnit: _isBtcUnit, + selectedAnalysisTransactionType: _selectedAnalysisTransactionType, + ); + _isLatestTxAnalysisRunning = false; + notifyListeners(); + } + + void setAnalysisPeriod(int value) { + _analysisPeriod = value; + _preferenceProvider.setAnalysisPeriod(value); + getRecentTransactionAnalysis(value); + notifyListeners(); + } + + void setAnalysisTransactionType(AnalysisTransactionType value) { + _selectedAnalysisTransactionType = value; + _preferenceProvider.setAnalysisTransactionType(value); + + getRecentTransactionAnalysis(_analysisPeriod); + notifyListeners(); + } + @override void dispose() { + _currentBlockSubscription?.cancel(); _syncNodeStateSubscription?.cancel(); _nodeProvider.removeListener(_onNodeProviderChanged); super.dispose(); } } + +class RecentTransactionAnalysis { + final int totalAmount; + final DateTime? startDate; + final DateTime? endDate; + final List receivedTxs; + final List sentTxs; + final List selfTxs; + final int receivedAmount; + final int sentAmount; + final int selfAmount; + final int days; + final bool isBtcUnit; + final AnalysisTransactionType selectedAnalysisTransactionType; + + const RecentTransactionAnalysis({ + required this.totalAmount, + required this.startDate, + required this.endDate, + required this.receivedTxs, + required this.sentTxs, + required this.selfTxs, + required this.receivedAmount, + required this.sentAmount, + required this.selfAmount, + required this.days, + required this.isBtcUnit, + required this.selectedAnalysisTransactionType, + }); + + bool get isEmpty => + receivedTxs.isEmpty && + sentTxs.isEmpty && + selfTxs.isEmpty && + receivedAmount == 0 && + sentAmount == 0 && + selfAmount == 0; + + int get totalTransactionCount => receivedTxs.length + sentTxs.length + selfTxs.length; + String get totalTransactionResult => + selectedAnalysisTransactionType == AnalysisTransactionType.onlyReceived + ? t.wallet_home_screen.received + : selectedAnalysisTransactionType == AnalysisTransactionType.onlySent + ? t.wallet_home_screen.sent + : totalAmount > 0 + ? t.wallet_home_screen.increase + : t.wallet_home_screen.decrease; + String get dateRange => '$_startDate ~ $_endDate'; + String get _startDate => + days == 0 + ? _formatYyMmDd(startDate ?? DateTime.now().subtract(Duration(days: days))) + : _formatYyMmDd(DateTime.now().subtract(Duration(days: days))); + String get _endDate => days == 0 ? _formatYyMmDd(endDate ?? DateTime.now()) : _formatYyMmDd(DateTime.now()); + + String get titleString => + '${isBtcUnit ? BitcoinUnit.btc.displayBitcoinAmount(selectedAnalysisTransactionType == AnalysisTransactionType.onlyReceived + ? receivedAmount + : selectedAnalysisTransactionType == AnalysisTransactionType.onlySent + ? (sentAmount + selfAmount).abs() + : totalAmount, withUnit: true) : BitcoinUnit.sats.displayBitcoinAmount(selectedAnalysisTransactionType == AnalysisTransactionType.onlyReceived + ? receivedAmount + : selectedAnalysisTransactionType == AnalysisTransactionType.onlySent + ? sentAmount + selfAmount + : totalAmount, withUnit: true)} '; + String get totalAmountResult => totalTransactionResult; + String get subtitleString => + '$dateRange | ${t.wallet_home_screen.transaction_count(count: selectedAnalysisTransactionType == TransactionType.received + ? receivedTxs.length.toString() + : selectedAnalysisTransactionType == TransactionType.sent + ? (sentTxs.length + selfTxs.length).toString() + : totalTransactionCount.toString())}'; + + String _formatYyMmDd(DateTime dt) { + final local = dt.toLocal(); + final y = local.year % 100; + final m = local.month.toString().padLeft(2, '0'); + final d = local.day.toString().padLeft(2, '0'); + final yy = y.toString().padLeft(2, '0'); + return '$yy.$m.$d'; + } +} + +enum AnalysisTransactionType { onlySent, onlyReceived, all } diff --git a/lib/providers/view_model/home/wallet_list_view_model.dart b/lib/providers/view_model/home/wallet_list_view_model.dart index 6d9524f81..88a547165 100644 --- a/lib/providers/view_model/home/wallet_list_view_model.dart +++ b/lib/providers/view_model/home/wallet_list_view_model.dart @@ -6,7 +6,7 @@ import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/providers/auth_provider.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/repository/shared_preference/shared_prefs_repository.dart'; diff --git a/lib/providers/view_model/send/refactor/send_view_model.dart b/lib/providers/view_model/send/refactor/send_view_model.dart index 30664d71f..eb8275605 100644 --- a/lib/providers/view_model/send/refactor/send_view_model.dart +++ b/lib/providers/view_model/send/refactor/send_view_model.dart @@ -11,7 +11,7 @@ import 'package:coconut_wallet/model/send/fee_info.dart'; import 'package:coconut_wallet/model/utxo/utxo_state.dart'; import 'package:coconut_wallet/model/wallet/wallet_address.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/screens/send/refactor/send_screen.dart'; diff --git a/lib/providers/view_model/send/refactor/utxo_selection_view_model.dart b/lib/providers/view_model/send/refactor/utxo_selection_view_model.dart index e9686a8c5..2e14a61fe 100644 --- a/lib/providers/view_model/send/refactor/utxo_selection_view_model.dart +++ b/lib/providers/view_model/send/refactor/utxo_selection_view_model.dart @@ -5,7 +5,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/send/fee_info.dart'; import 'package:coconut_wallet/model/utxo/utxo_state.dart'; import 'package:coconut_wallet/model/utxo/utxo_tag.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; diff --git a/lib/providers/view_model/settings/electrum_server_view_model.dart b/lib/providers/view_model/settings/electrum_server_view_model.dart index 0012e680a..cfac35a00 100644 --- a/lib/providers/view_model/settings/electrum_server_view_model.dart +++ b/lib/providers/view_model/settings/electrum_server_view_model.dart @@ -3,7 +3,7 @@ import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/enums/electrum_enums.dart'; import 'package:coconut_wallet/model/node/electrum_server.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/screens/settings/electrum_server_screen.dart'; import 'package:coconut_wallet/utils/logger.dart'; import 'package:flutter/material.dart'; diff --git a/lib/providers/view_model/settings/settings_view_model.dart b/lib/providers/view_model/settings/settings_view_model.dart index 7c8996552..0e8eb4166 100644 --- a/lib/providers/view_model/settings/settings_view_model.dart +++ b/lib/providers/view_model/settings/settings_view_model.dart @@ -1,5 +1,5 @@ import 'package:coconut_wallet/providers/auth_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:flutter/material.dart'; class SettingsViewModel extends ChangeNotifier { diff --git a/lib/providers/view_model/wallet_detail/utxo_list_view_model.dart b/lib/providers/view_model/wallet_detail/utxo_list_view_model.dart index bb8073407..522439aa6 100644 --- a/lib/providers/view_model/wallet_detail/utxo_list_view_model.dart +++ b/lib/providers/view_model/wallet_detail/utxo_list_view_model.dart @@ -10,7 +10,7 @@ import 'package:coconut_wallet/model/utxo/utxo_state.dart'; import 'package:coconut_wallet/model/utxo/utxo_tag.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; diff --git a/lib/providers/wallet_provider.dart b/lib/providers/wallet_provider.dart index 84fd05ae0..db34d06c0 100644 --- a/lib/providers/wallet_provider.dart +++ b/lib/providers/wallet_provider.dart @@ -11,15 +11,17 @@ import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/model/wallet/wallet_address.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/model/wallet/watch_only_wallet.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/home/wallet_add_scanner_view_model.dart'; import 'package:coconut_wallet/repository/realm/address_repository.dart'; import 'package:coconut_wallet/repository/realm/transaction_repository.dart'; import 'package:coconut_wallet/repository/realm/utxo_repository.dart'; import 'package:coconut_wallet/repository/realm/wallet_repository.dart'; +import 'package:coconut_wallet/services/model/response/block_timestamp.dart'; import 'package:coconut_wallet/utils/logger.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:tuple/tuple.dart'; typedef WalletUpdateListener = void Function(WalletUpdateInfo walletUpdateInfo); @@ -43,6 +45,7 @@ class WalletProvider extends ChangeNotifier { late final ValueNotifier walletLoadStateNotifier; late final ValueNotifier> walletItemListNotifier; + late final ValueNotifier currentBlockHeightNotifier; void _setWalletItemList(List value) { _walletItemList = value; @@ -60,9 +63,10 @@ class WalletProvider extends ChangeNotifier { // ValueNotifier들 초기화 walletLoadStateNotifier = ValueNotifier(_walletLoadState); walletItemListNotifier = ValueNotifier(_walletItemList); + currentBlockHeightNotifier = ValueNotifier(null); _loadWalletListFromDB().then((_) { - _fetchWalletPreferences(); // 이전 버전에서의 지갑목록과 충돌을 없애기 위한 초기화 + _preferenceProvider.setWalletPreferences(walletItemList); // 이전 버전에서의 지갑목록과 충돌을 없애기 위한 초기화 notifyListeners(); }); } @@ -383,6 +387,61 @@ class WalletProvider extends ChangeNotifier { return _transactionRepository.getTransactionRecordList(walletId); } + Map> getPendingAndDaysAgoTransactions( + List walletIds, + int currentBlockHeight, + int days, + ) { + Map> result = {}; + // 7일 전 블록 높이 (1008 blocks = 6 block/h * 24h * 7d) + // N일 전 블록 높이 (144 * N blocks = 6 block/h * 24h * Nd) + + final blockHeight = currentBlockHeight - (144 * days) > 0 ? currentBlockHeight - (144 * days) : 0; + for (int walletId in walletIds) { + final pendingTxs = _transactionRepository.getUnconfirmedTransactionRecordList(walletId); + // 현재 트랜잭션 기준 N일 내 트랜잭션 조회 + final recentTxs = _transactionRepository.getTransactionRecordListAfterBlockHeight(walletId, blockHeight); + + if (pendingTxs.isNotEmpty || recentTxs.isNotEmpty) { + result.addAll({ + walletId: [...pendingTxs, ...recentTxs], + }); + } + } + + return result; + } + + List getConfirmedTransactionRecordListWithin( + List walletIds, + int currentBlockHeight, + int daysAgo, + ) { + final startBlockHeight = currentBlockHeight - 6 * 24 * daysAgo > 0 ? currentBlockHeight - 6 * 24 * daysAgo : 0; + + List result = []; + for (int walletId in walletIds) { + final transactions = _transactionRepository.getTransactionRecordListAfterBlockHeight(walletId, startBlockHeight); + result.addAll(transactions); + } + + return result; + } + + List getConfirmedTransactionRecordListWithinDateRange( + List walletIds, + int currentBlockHeight, + Tuple2 dateRange, + ) { + List result = []; + for (int walletId in walletIds) { + final transactions = _transactionRepository.getTransactionRecordListWithDateRange(walletId, dateRange); + result.addAll(transactions); + } + + return result; + } + UtxoState? getUtxoState(int walletId, String utxoId) { return _utxoRepository.getUtxoState(walletId, utxoId); } @@ -415,18 +474,8 @@ class WalletProvider extends ChangeNotifier { ); } - /// DB에서 지갑 로드(_loadWalletListFromDB) 완료 후 수행 - Future _fetchWalletPreferences() async { - var walletOrder = _preferenceProvider.walletOrder; - var favoriteWalletIds = _preferenceProvider.favoriteWalletIds; - if (walletOrder.isEmpty) { - walletOrder = List.from(walletItemList.map((w) => w.id)); - await _preferenceProvider.setWalletOrder(walletOrder); - } - if (favoriteWalletIds.isEmpty) { - favoriteWalletIds = List.from(walletItemList.take(5).map((w) => w.id)); - await _preferenceProvider.setFavoriteWalletIds(favoriteWalletIds); - } + void setCurrentBlockHeight(BlockTimestamp? blockHeight) { + currentBlockHeightNotifier.value = blockHeight; } /// 새 지갑이 추가되었을 때 처리하는 함수(가짜 잔액 재분배, 즐겨찾기, 지갑 순서 추가) @@ -453,6 +502,7 @@ class WalletProvider extends ChangeNotifier { // ValueNotifier들 해제 walletLoadStateNotifier.dispose(); walletItemListNotifier.dispose(); + currentBlockHeightNotifier.dispose(); super.dispose(); } } diff --git a/lib/repository/realm/transaction_repository.dart b/lib/repository/realm/transaction_repository.dart index 35060b5cd..770887d4f 100644 --- a/lib/repository/realm/transaction_repository.dart +++ b/lib/repository/realm/transaction_repository.dart @@ -13,6 +13,7 @@ import 'package:coconut_wallet/services/model/response/fetch_transaction_respons import 'package:coconut_wallet/utils/result.dart'; import 'package:realm/realm.dart'; import 'package:coconut_wallet/utils/logger.dart'; +import 'package:tuple/tuple.dart'; class TransactionRepository extends BaseRepository { TransactionRepository(super._realmManager); @@ -65,6 +66,41 @@ class TransactionRepository extends BaseRepository { ]).toList(); } + List getTransactionRecordListAfterBlockHeight(int walletId, int blockHeight) { + final realmTxs = realm.query( + 'walletId == $walletId AND blockHeight >= $blockHeight SORT(createdAt DESC)', + ); + if (realmTxs.isEmpty) return []; + List result = []; + + for (var t in realmTxs) { + result.add(mapRealmTransactionToTransaction(t)); + } + + return result; + } + + List getTransactionRecordListWithDateRange(int walletId, Tuple2 dateRange) { + final rawStart = dateRange.item1.isBefore(dateRange.item2) ? dateRange.item1 : dateRange.item2; + final rawEnd = dateRange.item2.isAfter(dateRange.item1) ? dateRange.item2 : dateRange.item1; + // 날짜만 비교하도록 00:00:00 ~ 다음날 00:00:00(미포함) 범위로 정규화 + final startOnly = DateTime(rawStart.year, rawStart.month, rawStart.day); + final endExclusive = DateTime(rawEnd.year, rawEnd.month, rawEnd.day).add(const Duration(days: 1)); + + final realmTxs = realm.query( + r'walletId == $0 AND timestamp >= $1 AND timestamp < $2 SORT(createdAt DESC)', + [walletId, startOnly, endExclusive], + ); + if (realmTxs.isEmpty) return []; + List result = []; + + for (var t in realmTxs) { + result.add(mapRealmTransactionToTransaction(t)); + } + + return result; + } + /// walletId, transactionHash 로 조회된 transaction 의 메모 변경 Result updateTransactionMemo(int walletId, String txHash, String memo) { final realmMemo = realm.find(getTransactionMemoId(txHash, walletId)); diff --git a/lib/screens/home/analysis_period_bottom_sheet.dart b/lib/screens/home/analysis_period_bottom_sheet.dart new file mode 100644 index 000000000..f2a3befb7 --- /dev/null +++ b/lib/screens/home/analysis_period_bottom_sheet.dart @@ -0,0 +1,383 @@ +import 'package:coconut_design_system/coconut_design_system.dart'; +import 'package:coconut_wallet/localization/strings.g.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; +import 'package:coconut_wallet/providers/view_model/home/wallet_home_view_model.dart'; +import 'package:coconut_wallet/widgets/button/fixed_bottom_button.dart'; +import 'package:coconut_wallet/widgets/button/shrink_animation_button.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:provider/provider.dart'; + +class AnalysisPeriodBottomSheet extends StatefulWidget { + final void Function(int days) onSelected; + final void Function(AnalysisTransactionType transactionType) onTransactionTypeSelected; + final int initialPeriodPreset; + final AnalysisTransactionType initialAnalysisTransactionType; + const AnalysisPeriodBottomSheet({ + super.key, + required this.onSelected, + required this.onTransactionTypeSelected, + this.initialPeriodPreset = 30, + this.initialAnalysisTransactionType = AnalysisTransactionType.all, + }); + + @override + State createState() => _AnalysisPeriodBottomSheetState(); +} + +class _AnalysisPeriodBottomSheetState extends State { + final List presets = const [30, 60, 90, 0]; + final List transactionTypes = const [ + AnalysisTransactionType.all, + AnalysisTransactionType.onlySent, + AnalysisTransactionType.onlyReceived, + ]; + late List _selectedPeriodIndices; + late AnalysisTransactionType _selectedAnalysisTransactionType; + + DateTime? _startDate; + DateTime? _endDate; + + Future _showDateSpinner({required bool isStart, ValueChanged? onDateChanged}) async { + final now = DateTime.now(); + final initial = isStart ? (_startDate ?? now) : _endDate; + final firstDate = isStart ? DateTime(2009, 1, 3) : (_startDate ?? DateTime(2009, 1, 3)); + final today = DateTime(now.year, now.month, now.day); + final currentLanguage = Provider.of(context, listen: false).language; + final isKorean = currentLanguage == 'kr'; + final isEnglish = currentLanguage == 'en'; + debugPrint('DEBUG11 - _showDateSpinner: $isStart'); + await showCupertinoModalPopup( + context: context, + builder: (context) { + DateTime temp = initial!; + final DateTime maxDate = isStart ? _endDate ?? today : today; // 종료는 무조건 오늘까지 + return Localizations.override( + context: context, + locale: + isKorean + ? const Locale('ko', 'KR') + : isEnglish + ? const Locale('en', 'US') + : const Locale('ja', 'JP'), + delegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + child: CupertinoTheme( + data: CupertinoThemeData( + primaryColor: CupertinoColors.white, // 선택 포커스/악센트 + textTheme: CupertinoTextThemeData( + dateTimePickerTextStyle: CoconutTypography.heading3_21.setColor(CoconutColors.white), + ), + ), + child: SafeArea( + bottom: true, + child: Container( + color: CoconutColors.black, + height: 300, + child: Column( + children: [ + SizedBox( + height: 216, + child: Builder( + builder: (context) { + final DateTime initialClamped = () { + DateTime v = DateTime(temp.year, temp.month, temp.day); + if (v.isBefore(firstDate)) return firstDate; + if (v.isAfter(maxDate)) return maxDate; + return v; + }(); + return CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: initialClamped, + minimumDate: firstDate, + maximumDate: maxDate, + minimumYear: firstDate.year, + maximumYear: maxDate.year, + onDateTimeChanged: (d) { + temp = DateTime(d.year, d.month, d.day); + }, + ); + }, + ), + ), + CoconutLayout.spacing_200h, + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: SizedBox( + width: MediaQuery.sizeOf(context).width, + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), + child: Row( + children: [ + Flexible( + flex: 1, + child: SizedBox( + width: MediaQuery.sizeOf(context).width, + child: ShrinkAnimationButton( + defaultColor: CoconutColors.white, + pressedColor: CoconutColors.gray350, + onPressed: () => Navigator.pop(context), + borderRadius: CoconutStyles.radius_200, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + t.cancel, + textAlign: TextAlign.center, + style: CoconutTypography.body2_14_Bold.setColor(CoconutColors.black), + ), + ), + ), + ), + ), + CoconutLayout.spacing_200w, + Flexible( + flex: 2, + child: SizedBox( + width: MediaQuery.sizeOf(context).width, + child: ShrinkAnimationButton( + defaultColor: CoconutColors.white, + pressedColor: CoconutColors.gray350, + onPressed: () { + if (onDateChanged != null) { + onDateChanged(temp); + } + Navigator.pop(context); + }, + borderRadius: CoconutStyles.radius_200, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + t.confirm, + textAlign: TextAlign.center, + style: CoconutTypography.body2_14_Bold.setColor(CoconutColors.black), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + CoconutLayout.spacing_200h, + ], + ), + ), + ), + ), + ); + }, + ); + } + + String _fmt(DateTime? d) { + if (d == null) return t.confirm; + return '${d.year}.${d.month.toString().padLeft(2, '0')}.${d.day.toString().padLeft(2, '0')}'; + } + + @override + void initState() { + super.initState(); + _selectedPeriodIndices = List.generate(presets.length, (index) => presets[index] == widget.initialPeriodPreset); + _selectedAnalysisTransactionType = widget.initialAnalysisTransactionType; + + _startDate = getStartDateFromInitialPeriodPreset(); + _endDate = context.read().analysisPeriodRange.item2 ?? DateTime.now(); + } + + DateTime getStartDateFromInitialPeriodPreset() { + if (_selectedPeriodIndices[0]) { + return DateTime.now().subtract(const Duration(days: 30)); + } else if (_selectedPeriodIndices[1]) { + return DateTime.now().subtract(const Duration(days: 60)); + } else if (_selectedPeriodIndices[2]) { + return DateTime.now().subtract(const Duration(days: 90)); + } else { + return context.read().analysisPeriodRange.item1 ?? DateTime.now(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: CoconutColors.black, + body: SafeArea( + bottom: true, + child: Container( + color: CoconutColors.black, + child: Stack( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + color: Colors.transparent, + child: Center( + child: Container( + width: 55, + height: 4, + decoration: BoxDecoration( + color: CoconutColors.gray400, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + CoconutLayout.spacing_400h, + Text( + t.wallet_home_screen.analysis_period_bottom_sheet.period_for_analysis, + style: CoconutTypography.body1_16_Bold, + ), + CoconutLayout.spacing_300h, + MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), + child: CoconutSegmentedControl( + labels: [ + t.wallet_home_screen.analysis_period_bottom_sheet.days_30, + t.wallet_home_screen.analysis_period_bottom_sheet.days_60, + t.wallet_home_screen.analysis_period_bottom_sheet.days_90, + t.wallet_home_screen.analysis_period_bottom_sheet.custom, + ], + isSelected: _selectedPeriodIndices, + onPressed: (index) { + if (index == 3) { + _startDate = getStartDateFromInitialPeriodPreset(); + _endDate = DateTime.now(); + } + setState(() { + _selectedPeriodIndices = [index == 0, index == 1, index == 2, index == 3]; + }); + }, + ), + ), + CoconutLayout.spacing_500h, + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: + _selectedPeriodIndices[3] + ? Row( + children: [ + Expanded( + child: CoconutButton( + height: 50, + onPressed: + () => _showDateSpinner( + isStart: true, + onDateChanged: (d) => setState(() => _startDate = d), + ), + backgroundColor: CoconutColors.gray700, + borderWidth: 1, + buttonType: CoconutButtonType.outlined, + foregroundColor: CoconutColors.black, + textStyle: CoconutTypography.body2_14, + text: _fmt(_startDate), + ), + ), + CoconutLayout.spacing_200w, + const Text('~'), + CoconutLayout.spacing_200w, + Expanded( + child: CoconutButton( + height: 50, + onPressed: + () => _showDateSpinner( + isStart: false, + onDateChanged: (d) => setState(() => _endDate = d), + ), + backgroundColor: CoconutColors.gray700, + borderWidth: 1, + buttonType: CoconutButtonType.outlined, + foregroundColor: CoconutColors.black, + textStyle: CoconutTypography.body2_14, + text: _fmt(_endDate), + ), + ), + ], + ) + : const SizedBox.shrink(), + ), + CoconutLayout.spacing_400h, + Text( + t.wallet_home_screen.analysis_period_bottom_sheet.transaction_type, + style: CoconutTypography.body1_16_Bold, + ), + CoconutLayout.spacing_300h, + MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)), + child: CoconutSegmentedControl( + labels: [t.all, t.send, t.receive], + isSelected: transactionTypes.map((type) => type == _selectedAnalysisTransactionType).toList(), + onPressed: (index) { + setState(() { + _selectedAnalysisTransactionType = transactionTypes[index]; + }); + // 외부 콜백 호출 제거: 확인 버튼에서만 적용 + }, + ), + ), + ], + ), + ), + FixedBottomButton( + onButtonClicked: () { + final selectedIndex = + _selectedPeriodIndices.indexWhere((e) => e) == -1 + ? 0 + : _selectedPeriodIndices.indexWhere((e) => e); + if (selectedIndex == 3) { + context.read().setAnalysisPeriodRange(_startDate!, _endDate ?? DateTime.now()); + } + widget.onSelected(presets[selectedIndex]); + + widget.onTransactionTypeSelected(_selectedAnalysisTransactionType); + Navigator.pop(context); + }, + backgroundColor: CoconutColors.white, + isActive: + (() { + final selectedIndex = + _selectedPeriodIndices.indexWhere((e) => e) == -1 + ? 0 + : _selectedPeriodIndices.indexWhere((e) => e); + final int initialIndex = + widget.initialPeriodPreset == 0 ? 3 : presets.indexOf(widget.initialPeriodPreset); + bool changedDays; + if (selectedIndex == 3 && initialIndex != 3) { + changedDays = true; + } + if (selectedIndex == 3) { + final initialRange = context.read().analysisPeriodRange; + final DateTime? initialStart = initialRange.item1; + final DateTime? initialEnd = initialRange.item2; + + DateTime? dOnly(DateTime? d) => d == null ? null : DateTime(d.year, d.month, d.day); + + changedDays = dOnly(_startDate) != dOnly(initialStart) || dOnly(_endDate) != dOnly(initialEnd); + } else { + changedDays = selectedIndex != initialIndex; + } + final bool changedType = + _selectedAnalysisTransactionType != widget.initialAnalysisTransactionType; + return changedDays || changedType; + })(), + text: t.confirm, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home/wallet_add_input_screen.dart b/lib/screens/home/wallet_add_input_screen.dart index 3274462dd..39596c09c 100644 --- a/lib/screens/home/wallet_add_input_screen.dart +++ b/lib/screens/home/wallet_add_input_screen.dart @@ -4,7 +4,7 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/analytics/analytics_event_names.dart'; import 'package:coconut_wallet/analytics/analytics_parameter_names.dart'; import 'package:coconut_wallet/enums/wallet_enums.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/home/wallet_add_input_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/screens/home/wallet_add_mfp_input_bottom_sheet.dart'; diff --git a/lib/screens/home/wallet_add_scanner_screen.dart b/lib/screens/home/wallet_add_scanner_screen.dart index 2c73ec80b..bd37fa8df 100644 --- a/lib/screens/home/wallet_add_scanner_screen.dart +++ b/lib/screens/home/wallet_add_scanner_screen.dart @@ -7,7 +7,7 @@ import 'package:coconut_wallet/analytics/analytics_event_names.dart'; import 'package:coconut_wallet/analytics/analytics_parameter_names.dart'; import 'package:coconut_wallet/enums/wallet_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/home/wallet_add_scanner_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/screens/wallet_detail/wallet_info_screen.dart'; diff --git a/lib/screens/home/wallet_home_edit_bottom_sheet.dart b/lib/screens/home/wallet_home_edit_bottom_sheet.dart new file mode 100644 index 000000000..3dee711cd --- /dev/null +++ b/lib/screens/home/wallet_home_edit_bottom_sheet.dart @@ -0,0 +1,644 @@ +import 'package:coconut_design_system/coconut_design_system.dart'; +import 'package:coconut_wallet/localization/strings.g.dart'; +import 'package:coconut_wallet/model/preference/home_feature.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; +import 'package:coconut_wallet/providers/view_model/home/wallet_home_edit_view_model.dart'; +import 'package:coconut_wallet/providers/wallet_provider.dart'; +import 'package:coconut_wallet/utils/balance_format_util.dart'; +import 'package:coconut_wallet/widgets/button/fixed_bottom_button.dart'; +import 'package:coconut_wallet/widgets/button/shrink_animation_button.dart'; +import 'package:coconut_wallet/widgets/button/single_button.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +enum FakeBalanceInputError { + none, + notEnoughForAllWallets, // 지갑 수만큼 1사토시 이상 배정 불가한 경우 + exceedsTotalSupply, // 2100만 BTC를 초과하는 경우 +} + +class WalletHomeEditBottomSheet extends StatefulWidget { + const WalletHomeEditBottomSheet({super.key, required this.scrollController}); + final ScrollController scrollController; + + @override + State createState() => _WalletHomeEditBottomSheetState(); +} + +class _WalletHomeEditBottomSheetState extends State with TickerProviderStateMixin { + final TextEditingController _textEditingController = TextEditingController(); + late WalletHomeEditViewModel _viewModel; + + GlobalKey fixedBottomButtonKey = GlobalKey(); + Size _fixedBottomButtonSize = const Size(0, 0); + + final FocusNode _textFieldFocusNode = FocusNode(); + bool _showFakeBalanceInput = false; + bool _isRenderComplete = false; + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // 하단 버튼 사이즈 계산 + if (fixedBottomButtonKey.currentContext != null) { + final fixedBottomButtonRenderBox = fixedBottomButtonKey.currentContext?.findRenderObject() as RenderBox; + setState(() { + _fixedBottomButtonSize = fixedBottomButtonRenderBox.size; + }); + } + + if (_viewModel.tempFakeBalanceTotalBtc != null) { + if (_viewModel.tempFakeBalanceTotalBtc == 0) { + // 0일 때 + _textEditingController.text = '0'; + } else if (_viewModel.tempFakeBalanceTotalBtc! % 1 == 0) { + // 정수일 때 + _textEditingController.text = _viewModel.tempFakeBalanceTotalBtc.toString().split('.')[0]; + } else { + // 아주 작은 소수일 때 e-8로 표시되는 경우가 있음 -> toStringAsFixed(8)로 8자리까지 표시 후 뒤에 0이 있으면 제거 + _textEditingController.text = _viewModel.tempFakeBalanceTotalBtc! + .toStringAsFixed(8) + .replaceFirst(RegExp(r'\.?0+$'), ''); + } + } + + if (_viewModel.tempIsFakeBalanceActive) { + setState(() { + _showFakeBalanceInput = true; + }); + } + + _textFieldFocusNode.addListener(() { + if (_textFieldFocusNode.hasFocus) { + if (widget.scrollController.hasClients) { + debugPrint('animateTo: ${_fixedBottomButtonSize.height}'); + + widget.scrollController.animateTo( + _fixedBottomButtonSize.height, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + } + }); + + _textEditingController.addListener(() { + double? input; + if (_textEditingController.text.isEmpty) { + _viewModel.setTempFakeBalanceTotalBtc(null); + } + try { + input = double.parse(_textEditingController.text); + } catch (e) { + debugPrint(e.toString()); + } + + setState(() { + _viewModel.setTempFakeBalanceTotalBtc(input); + if (_viewModel.tempFakeBalanceTotalBtc == null) { + _viewModel.setInputError(FakeBalanceInputError.none); + } else { + if (_viewModel.tempFakeBalanceTotalBtc! > _viewModel.maximumAmount) { + _viewModel.setInputError(FakeBalanceInputError.exceedsTotalSupply); + } else { + _viewModel.setInputError(FakeBalanceInputError.none); + } + } + }); + }); + + setState(() { + _isRenderComplete = true; + }); + }); + } + + @override + void dispose() { + _textEditingController.dispose(); + _textFieldFocusNode.dispose(); + super.dispose(); + } + + WalletHomeEditViewModel _createViewModel() { + _viewModel = WalletHomeEditViewModel(context.read(), context.read()); + return _viewModel; + } + + void _onFakeBalanceToggleChanged(bool value) { + if (value) { + setState(() { + _showFakeBalanceInput = true; + }); + } else { + setState(() { + _showFakeBalanceInput = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider2( + create: (context) => _createViewModel(), + update: (context, walletProvider, preferenceProvider, previous) { + previous ??= _createViewModel(); + previous.onPreferenceProviderUpdated(); + return previous..onWalletProviderUpdated(walletProvider); + }, + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: CoconutAppBar.build( + backgroundColor: CoconutColors.black, + context: context, + isBottom: true, + onBackPressed: () { + if (_shouldEnableCompleteButton()) { + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.wallet_list.edit.finish, + description: t.wallet_list.edit.unsaved_changes_confirm_exit, + leftButtonText: t.cancel, + rightButtonText: t.confirm, + onTapRight: () { + Navigator.pop(context); + Navigator.pop(context); + }, + onTapLeft: () { + Navigator.pop(context); + }, + ); + }, + ); + } else { + Navigator.pop(context); + } + }, + ), + body: SafeArea( + child: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: Container( + height: MediaQuery.sizeOf(context).height, + color: CoconutColors.black, + child: SingleChildScrollView( + controller: widget.scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoconutLayout.spacing_100h, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), + child: SizedBox( + width: MediaQuery.sizeOf(context).width / 3 * 2, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(t.wallet_home_screen.edit.title, style: CoconutTypography.heading3_21_Bold), + ), + ), + ), + const Divider(height: 1, color: CoconutColors.gray700), + if (context.read().walletItemList.isNotEmpty) ...[ + Consumer( + builder: (context, viewModel, child) { + return Column( + children: [ + SingleButton( + isVerticalSubtitle: true, + title: t.wallet_home_screen.edit.hide_balance, + subtitle: t.wallet_home_screen.edit.hide_balance_on_home, + subtitleStyle: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + customPadding: const EdgeInsets.fromLTRB(20, 16, 20, 10), + onPressed: () async { + if (_textFieldFocusNode.hasFocus) { + FocusScope.of(context).unfocus(); + return; + } + viewModel.setTempIsBalanceHidden(!viewModel.tempIsBalanceHidden); + }, + betweenGap: 16, + backgroundColor: CoconutColors.black, + rightElement: CoconutSwitch( + isOn: viewModel.tempIsBalanceHidden, + scale: 0.7, + activeColor: CoconutColors.gray100, + trackColor: CoconutColors.gray600, + thumbColor: CoconutColors.gray800, + onChanged: (value) { + viewModel.setTempIsBalanceHidden(value); + }, + ), + ), + SingleButton( + isVerticalSubtitle: true, + title: t.wallet_home_screen.edit.fake_balance.fake_balance_display, + subtitle: t.wallet_home_screen.edit.fake_balance.fake_balance_input_description, + subtitleStyle: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + customPadding: const EdgeInsets.fromLTRB(20, 10, 20, 10), + betweenGap: 16, + onPressed: () async { + if (_textFieldFocusNode.hasFocus) { + FocusScope.of(context).unfocus(); + return; + } + + viewModel.setTempFakeBalanceActive(!viewModel.tempIsFakeBalanceActive); + _onFakeBalanceToggleChanged(viewModel.tempIsFakeBalanceActive); + }, + backgroundColor: Colors.transparent, + rightElement: CoconutSwitch( + isOn: viewModel.tempIsFakeBalanceActive, + scale: 0.7, + activeColor: CoconutColors.gray100, + trackColor: CoconutColors.gray600, + thumbColor: CoconutColors.gray800, + onChanged: (value) { + viewModel.setTempFakeBalanceActive(value); + _onFakeBalanceToggleChanged(value); + }, + ), + ), + _buildDelayedFakeBalanceInput(), + SingleButton( + isVerticalSubtitle: true, + title: t.wallet_home_screen.edit.hide_fiat_price, + subtitle: t.wallet_home_screen.edit.hide_fiat_price_on_home, + subtitleStyle: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + customPadding: const EdgeInsets.fromLTRB(20, 10, 20, 16), + onPressed: () async { + if (_textFieldFocusNode.hasFocus) { + FocusScope.of(context).unfocus(); + return; + } + viewModel.setTempIsFiatBalanceHidden(!viewModel.tempIsFiatBalanceHidden); + }, + betweenGap: 16, + backgroundColor: CoconutColors.black, + rightElement: CoconutSwitch( + isOn: viewModel.tempIsFiatBalanceHidden, + scale: 0.7, + activeColor: CoconutColors.gray100, + trackColor: CoconutColors.gray600, + thumbColor: CoconutColors.gray800, + onChanged: (value) { + viewModel.setTempIsFiatBalanceHidden(value); + }, + ), + ), + ], + ); + }, + ), + const Divider(height: 1, color: CoconutColors.gray700), + ], + CoconutLayout.spacing_500h, + _buildHomeWidgetSelector(), + ], + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: -MediaQuery.of(context).viewInsets.bottom, + child: SizedBox( + height: 800, + child: Consumer( + builder: (context, viewModel, _) { + return FixedBottomButton( + gradientKey: fixedBottomButtonKey, + backgroundColor: CoconutColors.white, + isActive: _shouldEnableCompleteButton(), + onButtonClicked: () async { + FocusScope.of(context).unfocus(); + if (viewModel.tempIsFakeBalanceActive && _textEditingController.text.isEmpty) { + // 가짜 잔액을 활성화 했지만 금액을 입력하지 않았을 때 -> 0으로 설정할지 다시입력할지 물어봄 + showDialog( + context: context, + builder: (BuildContext context) { + return CoconutPopup( + languageCode: context.read().language, + title: t.wallet_home_screen.edit.alert.empty_fake_balance, + description: t.wallet_home_screen.edit.alert.empty_fake_balance_description, + leftButtonText: t.wallet_home_screen.edit.alert.enter_again, + rightButtonText: t.wallet_home_screen.edit.alert.set_to_0, + onTapRight: () async { + viewModel.setTempFakeBalanceTotalBtc(0); + + _onComplete(); + if (mounted) { + Navigator.pop(context); + Navigator.pop(context); + } + }, + onTapLeft: () { + Navigator.pop(context); + }, + ); + }, + ); + return; + } + + _onComplete(); + if (mounted) { + Navigator.pop(context); + } + }, + text: t.complete, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + bool _shouldEnableCompleteButton() { + final text = _textEditingController.text; + + final isToggleChanged = + _viewModel.tempIsFakeBalanceActive != _viewModel.isFakeBalanceActive || // 가짜잔액표시 변동 + _viewModel.tempIsBalanceHidden != _viewModel.isBalanceHidden || // 잔액숨기기 변동 + _viewModel.tempIsFiatBalanceHidden != _viewModel.isFiatBalanceHidden || // 법정화폐잔액숨기기 변동 + !_viewModel.tempHomeFeatures.every((tempFeature) { + // 홈 화면 기능 변동 + final original = _viewModel.homeFeatures.firstWhere( + (f) => f.homeFeatureTypeString == tempFeature.homeFeatureTypeString, + orElse: () => tempFeature, + ); + return tempFeature.isEnabled == original.isEnabled; + }); + if (_viewModel.tempIsFakeBalanceActive) { + if (_viewModel.fakeBalanceTotalAmount == null) return true; + final expectedText = UnitUtil.convertSatoshiToBitcoin( + _viewModel.fakeBalanceTotalAmount!, + ).toStringAsFixed(8).replaceFirst(RegExp(r'\.?0*$'), ''); + final isTextChanged = text != expectedText; + // 가짜 잔액 표시가 활성화 되더라도 입력값이 없으면 변동되지 않음 + if (!isTextChanged) return isToggleChanged; + + final parsed = double.tryParse(text); + if (parsed != 0 && _viewModel.inputError != FakeBalanceInputError.none) return false; + return true; + } + return isToggleChanged; + } + + void _onComplete() async { + if (_textEditingController.text.isEmpty) {} + await _viewModel.onComplete(); + } + + Widget _buildHomeWidgetSelector() { + return Consumer( + builder: (context, viewModel, _) { + final fixedWidgets = [ + {'homeFeatureTypeString': HomeFeatureType.totalBalance.name, 'icon': HomeFeatureType.totalBalance.assetPath}, + {'homeFeatureTypeString': HomeFeatureType.walletList.name, 'icon': HomeFeatureType.walletList.assetPath}, + ]; + final displayHomeWidgets = [ + ...fixedWidgets, + ..._viewModel.tempHomeFeatures.map( + (e) => { + 'homeFeatureTypeString': e.homeFeatureTypeString, + 'icon': + HomeFeatureType.values + .firstWhere( + (type) => type.name == e.homeFeatureTypeString, + orElse: () => HomeFeatureType.totalBalance, + ) + .assetPath, + 'isEnabled': e.isEnabled, + }, + ), + ]; + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + width: MediaQuery.sizeOf(context).width, + child: Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + // 한 줄에 몇 개 들어갈지 계산 (예: 3개) + double spacing = 12; + int itemsPerRow = 3; + + // 각 아이템의 너비 계산 (spacing과 패딩을 제외하고 가득 차도록 설정) + double itemWidth = (constraints.maxWidth - spacing * (itemsPerRow - 1)) / itemsPerRow; + + return Wrap( + spacing: spacing, + runSpacing: 14, + children: + displayHomeWidgets.map((widget) { + return SizedBox( + width: itemWidth, + height: itemWidth, + child: ShrinkAnimationButton( + isActive: + !fixedWidgets.any( + (fixed) => fixed['homeFeatureTypeString'] == widget['homeFeatureTypeString'], + ), + onPressed: () { + FocusScope.of(context).unfocus(); + // homeFeatureTypeString을 통해 토글 + _viewModel.toggleTempHomeFeatureEnabled(widget['homeFeatureTypeString'].toString()); + }, + defaultColor: CoconutColors.gray800, + pressedColor: CoconutColors.gray750, + child: Container( + height: 100, + width: 100, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Stack( + children: [ + Align( + alignment: Alignment.topLeft, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(1.0)), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _getHomeFeatureLabel(widget['homeFeatureTypeString'].toString()), + maxLines: 2, + style: CoconutTypography.body2_14.setColor(CoconutColors.white), + ), + ), + ), + ), + Align( + alignment: Alignment.topRight, + child: + (widget['homeFeatureTypeString'] == + HomeFeatureType.totalBalance.name || + widget['homeFeatureTypeString'] == + HomeFeatureType.walletList.name) + ? Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: CoconutColors.gray700.withValues(alpha: 0.5), + ), + child: Center( + child: SvgPicture.asset( + 'assets/svg/check.svg', + width: 6, + height: 6, + colorFilter: const ColorFilter.mode( + CoconutColors.gray800, + BlendMode.srcIn, + ), + ), + ), + ) + : AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + (widget['isEnabled'] as bool) + ? CoconutColors.white + : CoconutColors.gray800, + border: Border.all( + width: (widget['isEnabled'] as bool) ? 0 : 1.5, + color: CoconutColors.gray600, + ), + ), + child: Center( + child: SvgPicture.asset( + 'assets/svg/check.svg', + width: 6, + height: 6, + colorFilter: ColorFilter.mode( + (widget['isEnabled'] as bool) + ? CoconutColors.gray800 + : CoconutColors.gray600, + BlendMode.srcIn, + ), + ), + ), + ), + ), + ], + ), + const Spacer(), + SvgPicture.asset(widget['icon']!.toString(), width: 32), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + }, + ), + SizedBox(height: _fixedBottomButtonSize.height), + ], + ), + ), + ); + }, + ); + } + + String _getHomeFeatureLabel(String homeFeatureTypeString) { + final type = HomeFeatureType.values.firstWhere( + (e) => e.name == homeFeatureTypeString, + orElse: () => HomeFeatureType.totalBalance, + ); + if (type == HomeFeatureType.totalBalance) { + return t.wallet_home_screen.edit.category.total_balance; + } else if (type == HomeFeatureType.walletList) { + return t.wallet_home_screen.edit.category.wallet_list; + } else if (type == HomeFeatureType.recentTransaction) { + return t.wallet_home_screen.edit.category.recent_transactions; + } else if (type == HomeFeatureType.analysis) { + return t.wallet_home_screen.edit.category.analysis; + } + + return ''; + } + + Widget _buildDelayedFakeBalanceInput() { + return Consumer( + builder: (context, viewModel, child) { + if (!_isRenderComplete) { + return const SizedBox(height: 12); + } + return AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: const SizedBox(height: 0), + secondChild: Column( + children: [ + Container( + height: viewModel.tempIsFakeBalanceActive ? null : 0, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CoconutTextField( + textInputType: const TextInputType.numberWithOptions(decimal: true), + textInputFormatter: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,8}'))], + placeholderText: + viewModel.tempFakeBalanceTotalBtc != null + ? '' + : t.wallet_home_screen.edit.fake_balance.fake_balance_input_placeholder, + isLengthVisible: false, + controller: _textEditingController, + focusNode: _textFieldFocusNode, + onChanged: (text) {}, + backgroundColor: CoconutColors.black, + errorColor: CoconutColors.hotPink, + placeholderColor: CoconutColors.gray700, + activeColor: CoconutColors.white, + cursorColor: CoconutColors.white, + maxLength: viewModel.maxInputLength, + errorText: + _viewModel.inputError == FakeBalanceInputError.exceedsTotalSupply + ? ' ${t.wallet_home_screen.edit.fake_balance.fake_balance_input_exceeds_error}' + : '', + isError: _viewModel.inputError != FakeBalanceInputError.none, + maxLines: 1, + ), + ), + CoconutLayout.spacing_400h, + ], + ), + crossFadeState: viewModel.tempIsFakeBalanceActive ? CrossFadeState.showSecond : CrossFadeState.showFirst, + ); + }, + ); + } +} diff --git a/lib/screens/home/wallet_home_screen.dart b/lib/screens/home/wallet_home_screen.dart index ec8ac04b2..ceffbafb5 100644 --- a/lib/screens/home/wallet_home_screen.dart +++ b/lib/screens/home/wallet_home_screen.dart @@ -1,27 +1,34 @@ import 'dart:async'; import 'dart:io'; +import 'package:carousel_slider/carousel_slider.dart'; import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/constants/external_links.dart'; -import 'package:coconut_wallet/constants/icon_path.dart'; import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/enums/network_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/node/wallet_update_info.dart'; +import 'package:coconut_wallet/model/preference/home_feature.dart'; import 'package:coconut_wallet/model/wallet/balance.dart'; +import 'package:coconut_wallet/model/wallet/transaction_record.dart'; +import 'package:coconut_wallet/enums/transaction_enums.dart'; +import 'package:coconut_wallet/screens/home/analysis_period_bottom_sheet.dart'; +import 'package:coconut_wallet/utils/transaction_util.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/visibility_provider.dart'; +import 'package:coconut_wallet/screens/home/wallet_home_edit_bottom_sheet.dart'; import 'package:coconut_wallet/screens/home/wallet_list_user_experience_survey_bottom_sheet.dart'; import 'package:coconut_wallet/screens/wallet_detail/wallet_info_screen.dart'; +import 'package:coconut_wallet/utils/datetime_util.dart'; import 'package:coconut_wallet/utils/logger.dart'; import 'package:coconut_wallet/utils/uri_launcher.dart'; import 'package:coconut_wallet/widgets/animated_balance.dart'; +import 'package:coconut_wallet/widgets/animated_dots_text.dart'; import 'package:coconut_wallet/widgets/button/shrink_animation_button.dart'; -import 'package:coconut_wallet/widgets/card/donation_banner_card.dart'; import 'package:coconut_wallet/widgets/card/wallet_list_add_guide_card.dart'; import 'package:coconut_wallet/widgets/contents/fiat_price.dart'; import 'package:coconut_wallet/widgets/loading_indicator/loading_indicator.dart'; @@ -43,6 +50,7 @@ import 'package:coconut_wallet/widgets/overlays/common_bottom_sheets.dart'; import 'package:coconut_wallet/screens/home/wallet_list_glossary_bottom_sheet.dart'; import 'package:shimmer/shimmer.dart'; import 'package:tuple/tuple.dart'; +import 'package:collection/collection.dart'; class WalletHomeScreen extends StatefulWidget { const WalletHomeScreen({super.key}); @@ -56,7 +64,10 @@ class _WalletHomeScreenState extends State with TickerProvider Size _dropdownButtonSize = const Size(0, 0); Offset _dropdownButtonPosition = Offset.zero; final ValueNotifier _isDropdownMenuVisible = ValueNotifier(false); + bool _showEmptyRecentTransactionWidget = true; + Timer? _recentTransactionBannerTimer; late ScrollController _scrollController; + late CarouselSliderController _carouselController; DateTime? _lastPressedAt; ResultOfSyncFromVault? _resultOfSyncFromVault; @@ -73,6 +84,9 @@ class _WalletHomeScreenState extends State with TickerProvider bool _isFirstLoad = true; bool _isWalletListLoading = false; + int _recentTransactionCurrentPage = 0; + late ScrollController _pageIndicatorController; + @override Widget build(BuildContext context) { return ChangeNotifierProxyProvider4< @@ -107,7 +121,7 @@ class _WalletHomeScreenState extends State with TickerProvider Tuple7< List, List, - bool, + Tuple2, bool, Map, Tuple2>, @@ -118,7 +132,7 @@ class _WalletHomeScreenState extends State with TickerProvider (_, vm) => Tuple7( vm.walletItemList, vm.favoriteWallets, - vm.isBalanceHidden, + Tuple2(vm.isBalanceHidden, vm.isBalanceHidden), vm.shouldShowLoadingIndicator, vm.walletBalanceMap, Tuple2(vm.fakeBalanceTotalAmount, vm.fakeBalanceMap), @@ -129,10 +143,12 @@ class _WalletHomeScreenState extends State with TickerProvider final walletItem = data.item1; final favoriteWallets = data.item2; - final isBalanceHidden = data.item3; + final balnaceVisibilityData = data.item3; final shouldShowLoadingIndicator = data.item4; final walletBalanceMap = data.item5; + final fakeBalanceData = data.item6; final networkStatus = data.item7; + final homeFeatures = viewModel.homeFeatures; if (viewModel.isWalletListChanged(_previousWalletList, walletItem, walletBalanceMap)) { _handleWalletListUpdate(walletItem); @@ -159,10 +175,11 @@ class _WalletHomeScreenState extends State with TickerProvider CupertinoSliverRefreshControl(onRefresh: viewModel.onRefresh), _buildLoadingIndicator(viewModel), _buildHeader( - isBalanceHidden, - viewModel.fakeBalanceTotalAmount, + balnaceVisibilityData.item1, + balnaceVisibilityData.item2, + fakeBalanceData.item1, shouldShowLoadingIndicator, - viewModel.walletItemList.isEmpty, + walletItem.isEmpty, ), if (!shouldShowLoadingIndicator) SliverToBoxAdapter( @@ -191,10 +208,26 @@ class _WalletHomeScreenState extends State with TickerProvider walletItem, favoriteWallets, walletBalanceMap, - isBalanceHidden, + balnaceVisibilityData.item1, (id) => viewModel.getFakeBalance(id), ), + if (homeFeatures.isNotEmpty) ...[ + // 최근 트랜잭션 섹션: 로딩 중이면 스켈레톤, 아니면 컨텐츠 + buildFeatureSectionIfEnabled( + HomeFeatureType.recentTransaction, + () => + viewModel.isFetchingLatestTx + ? _buildRecentTransactionsSkeleton() + : _buildRecentTransactions(), + ), + // 분석 섹션: 로딩 중이면 스켈레톤, 아니면 컨텐츠 + buildFeatureSectionIfEnabled( + HomeFeatureType.analysis, + () => viewModel.isLatestTxAnalysisRunning ? _buildAnalysisSkeleton() : _buildAnalysis(), + ), + ], ], + if (walletItem.isNotEmpty) _buildHomeEditButton(), ], ), _buildDropdownBackdrop(), @@ -214,6 +247,8 @@ class _WalletHomeScreenState extends State with TickerProvider super.initState(); _scrollController = ScrollController(); + _carouselController = CarouselSliderController(); + _pageIndicatorController = ScrollController(); _dropdownActions = [ () => CommonBottomSheets.showCustomHeightBottomSheet( @@ -280,7 +315,9 @@ class _WalletHomeScreenState extends State with TickerProvider @override void dispose() { + _recentTransactionBannerTimer?.cancel(); _scrollController.dispose(); + _pageIndicatorController.dispose(); super.dispose(); } @@ -342,7 +379,7 @@ class _WalletHomeScreenState extends State with TickerProvider _previousWalletList = List.from(walletList); WidgetsBinding.instance.addPostFrameCallback((_) { - _viewModel.updateWalletBalances(); + _viewModel.updateWalletBalancesAndRecentTxs(); }); } finally { _isWalletListLoading = false; @@ -351,6 +388,7 @@ class _WalletHomeScreenState extends State with TickerProvider Widget _buildHeader( bool isBalanceHidden, + bool isFiatBalanceHidden, int? fakeBalanceTotalAmount, bool shouldShowLoadingIndicator, bool isWalletListEmpty, @@ -407,9 +445,13 @@ class _WalletHomeScreenState extends State with TickerProvider child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - Selector>( - selector: (_, viewModel) => viewModel.excludedFromTotalBalanceWalletIds, - builder: (context, excludedIds, child) { + Selector, bool>>( + selector: + (_, viewModel) => + Tuple2(viewModel.excludedFromTotalBalanceWalletIds, viewModel.isFiatBalanceHidden), + builder: (context, data, child) { + final excludedIds = data.item1; + final isFiatBalanceHidden = data.item2; final balance = _viewModel.fakeBalanceTotalAmount != null ? _viewModel.fakeBalanceMap.entries @@ -421,9 +463,15 @@ class _WalletHomeScreenState extends State with TickerProvider (entry) => !excludedIds.contains(entry.key), ), ).values.map((e) => e.current).fold(0, (current, element) => current + element); - return FiatPrice( - satoshiAmount: balance, - textStyle: CoconutTypography.body3_12_Number.setColor(CoconutColors.gray350), + return Visibility( + maintainSize: true, + maintainAnimation: true, + maintainState: true, + visible: !isFiatBalanceHidden, + child: FiatPrice( + satoshiAmount: balance, + textStyle: CoconutTypography.body3_12_Number.setColor(CoconutColors.gray350), + ), ); }, ), @@ -658,10 +706,6 @@ class _WalletHomeScreenState extends State with TickerProvider ); } - Widget _buildDonationBanner() { - return DonationBannerCard(walletListLength: _viewModel.walletItemList.length); - } - bool _checkStateAndShowToast(int id) { // if (_viewModel.isNetworkOn != true) { // CoconutToast.showWarningToast(context: context, text: ErrorCodes.networkError.message); @@ -711,6 +755,60 @@ class _WalletHomeScreenState extends State with TickerProvider Navigator.pushNamed(context, '/send', arguments: {'walletId': targetId, 'sendEntryPoint': SendEntryPoint.home}); } + Widget _buildHomeEditButton() { + return SliverToBoxAdapter( + child: Column( + children: [ + CoconutLayout.spacing_800h, + CoconutUnderlinedButton( + padding: const EdgeInsets.all(8), + onTap: () async { + await CommonBottomSheets.showDraggableBottomSheet( + minChildSize: 0.5, + maxChildSize: 0.9, + initialChildSize: 0.9, + context: context, + childBuilder: (controller) => WalletHomeEditBottomSheet(scrollController: controller), + ); + if (context.mounted) { + // PreferenceProvider의 변경사항을 ViewModel에 먼저 반영 + final preferenceProvider = context.read(); + // HomeFeatureProvider는 PreferenceProvider를 통해 접근 (Facade 패턴) + final homeFeatures = preferenceProvider.homeFeatures; + + // recentTransaction feature가 활성화되어 있으면 getPendingAndRecentDaysTransactions 실행 + final recentTransactionFeature = homeFeatures.firstWhereOrNull( + (f) => f.homeFeatureTypeString == HomeFeatureType.recentTransaction.name, + ); + if (recentTransactionFeature != null && recentTransactionFeature.isEnabled) { + if (_viewModel.currentBlock?.height != null) { + _viewModel.getPendingAndRecentDaysTransactions( + _viewModel.currentBlock!.height, + kRecenctTransactionDays, + ); + } + } + + // analysis feature가 활성화되어 있으면 getRecentTransactionAnalysis 실행 + final analysisFeature = homeFeatures.firstWhereOrNull( + (f) => f.homeFeatureTypeString == HomeFeatureType.analysis.name, + ); + if (analysisFeature != null && analysisFeature.isEnabled) { + setState(() { + _viewModel.getRecentTransactionAnalysis(_viewModel.analysisPeriod); + }); + } + } + }, + text: t.wallet_home_screen.edit_home_screen, + textStyle: CoconutTypography.body3_12, + ), + CoconutLayout.spacing_2500h, + ], + ), + ); + } + Widget _buildViewAll(int walletCount) { return SliverToBoxAdapter( child: Column( @@ -734,7 +832,7 @@ class _WalletHomeScreenState extends State with TickerProvider child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, - child: Text(t.wallet_list.view_all_wallets, style: CoconutTypography.body2_14), + child: Text(t.wallet_home_screen.view_all_wallets, style: CoconutTypography.body2_14), ), ), Row( @@ -847,6 +945,563 @@ class _WalletHomeScreenState extends State with TickerProvider ); } + Widget buildFeatureSectionIfEnabled(HomeFeatureType type, Widget Function() builder) { + final feature = _viewModel.homeFeatures.firstWhereOrNull((f) => f.homeFeatureTypeString == type.name); + if (feature != null && feature.isEnabled) { + return builder(); + } + + return SliverToBoxAdapter(child: Container()); + } + + Widget _buildRecentTransactions() { + return SliverToBoxAdapter( + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: CoconutColors.black), + child: Center( + child: Selector( + selector: (_, viewModel) => viewModel.isBtcUnit, + builder: (context, isBtcUnit, child) { + // 정렬된 트랜잭션 플랫 리스트 + final ordered = _getOrderedRecentTransactions(); + + if (ordered.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _buildEmptyRecentTransactions( + _viewModel.shouldShowLoadingIndicator && _viewModel.walletItemList.isNotEmpty, + ), + ); + } + + return ordered.length == 1 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _buildRecentTransactionCard(ordered.first.item1, ordered.first.item2, isBtcUnit), + ) + : CarouselSlider( + carouselController: _carouselController, + options: CarouselOptions( + autoPlay: false, + height: 90, + viewportFraction: 0.82, + enlargeCenterPage: true, + enlargeFactor: 0.25, + enableInfiniteScroll: false, + onPageChanged: (index, reason) { + setState(() { + _recentTransactionCurrentPage = index; + }); + // 인디케이터 자동 스크롤 + _scrollToIndicator(index); + }, + ), + items: + ordered.map((t) { + return _buildRecentTransactionCard(t.item1, t.item2, isBtcUnit); + }).toList(), + ); + }, + ), + ), + ), + // 페이지 인디케이터 (트랜잭션 단위, 2개 이상일 때만 표시) + Builder( + builder: (context) { + final totalCount = _getOrderedRecentTransactions().length; + + if (totalCount <= 1) return Container(); + + return Container( + margin: const EdgeInsets.only(top: 16, left: 50, right: 50), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _pageIndicatorController, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(totalCount, (index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: EdgeInsets.symmetric(horizontal: _recentTransactionCurrentPage == index ? 2 : 4), + width: _recentTransactionCurrentPage == index ? 12 : 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _recentTransactionCurrentPage == index ? CoconutColors.gray400 : CoconutColors.gray800, + ), + ); + }), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildRecentTransactionCard(int walletId, TransactionRecord transaction, bool isBtcUnit) { + final walletName = _viewModel.getWalletById(walletId).name; + + Widget buildTxRow(TransactionRecord transaction) { + final bool isReceived = transaction.transactionType == TransactionType.received; + final DateTime txDate = transaction.getDateTimeToDisplay()!.toLocal(); + final List transactionTimeStamp = DateTimeUtil.formatTimestamp(txDate); + final String amountString = + isBtcUnit + ? BitcoinUnit.btc.displayBitcoinAmount(transaction.amount, withUnit: true) + : BitcoinUnit.sats.displayBitcoinAmount(transaction.amount, withUnit: true); + final String prefix = isReceived ? '+' : ''; + final status = TransactionUtil.getStatus(transaction); + final String iconSource = switch (status) { + TransactionStatus.received => 'assets/svg/tx-received.svg', + TransactionStatus.receiving => 'assets/svg/tx-receiving.svg', + TransactionStatus.sent => 'assets/svg/tx-sent.svg', + TransactionStatus.sending => 'assets/svg/tx-sending.svg', + TransactionStatus.self => 'assets/svg/tx-self.svg', + TransactionStatus.selfsending => 'assets/svg/tx-self-sending.svg', + _ => 'assets/svg/tx-receiving.svg', + }; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset(iconSource, fit: BoxFit.fill, width: 24, height: 24), + CoconutLayout.spacing_300w, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Text( + transactionTimeStamp[0], + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + CoconutLayout.spacing_50w, + Text('|', style: CoconutTypography.body3_12.setColor(CoconutColors.gray400)), + CoconutLayout.spacing_50w, + Text( + transactionTimeStamp[1], + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + ], + ), + Text(walletName, style: CoconutTypography.body3_12.setColor(CoconutColors.gray400)), + ], + ), + ], + ), + ), + ), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + (() { + final now = DateTime.now(); + final diffDays = now.difference(txDate).inDays; + return t.relative_time.days_ago(n: diffDays); + })(), + style: CoconutTypography.body3_12, + ), + Text('$prefix $amountString', style: CoconutTypography.body2_14_Number), + ], + ), + ), + ), + ], + ); + } + + return ShrinkAnimationButton( + pressedColor: CoconutColors.gray750, + onPressed: + () => Navigator.pushNamed( + context, + '/transaction-detail', + arguments: {'id': walletId, 'txHash': transaction.transactionHash}, + ), + child: Container( + padding: const EdgeInsets.only(left: 20, right: 14, top: 20, bottom: 20), + decoration: const BoxDecoration(borderRadius: BorderRadius.all(Radius.circular(12))), + child: buildTxRow(transaction), + ), + ); + } + + Widget _buildRecentTransactionsSkeleton() { + return SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.only(top: 12, left: 20, right: 20), + child: Shimmer.fromColors( + baseColor: CoconutColors.gray800, + highlightColor: CoconutColors.gray750, + child: Container( + width: MediaQuery.sizeOf(context).width, + height: 90, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CoconutStyles.radius_200), + color: CoconutColors.gray800, + ), + child: const Text('', style: CoconutTypography.body2_14), + ), + ), + ), + ); + } + + Widget _buildEmptyRecentTransactions(bool isSyncing) { + if (isSyncing) { + _recentTransactionBannerTimer?.cancel(); + _recentTransactionBannerTimer = null; + _showEmptyRecentTransactionWidget = true; + } else if (_showEmptyRecentTransactionWidget && _recentTransactionBannerTimer == null) { + _recentTransactionBannerTimer = Timer(const Duration(seconds: 2), () { + if (!mounted) return; + setState(() { + _showEmptyRecentTransactionWidget = false; + }); + _recentTransactionBannerTimer = null; + }); + } + + final shouldShow = isSyncing || _showEmptyRecentTransactionWidget; + + return AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: + shouldShow + ? Container( + padding: const EdgeInsets.only(left: 20, right: 14, top: 20, bottom: 20), + decoration: const BoxDecoration( + color: CoconutColors.gray800, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Center( + child: + isSyncing + ? AnimatedDotsText( + text: t.wallet_home_screen.syncing_recent_transaction, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ) + : Text( + t.wallet_home_screen.empty_recent_transaction, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + ), + ) + : const SizedBox.shrink(), + ); + } + + // 최근 트랜잭션을 플랫하고 정렬(pending 최신순 → confirmed 최신순)하여 반환 + List> _getOrderedRecentTransactions() { + final flatTxs = + _viewModel.recentTransactions.entries + .expand((entry) => entry.value.map((tx) => Tuple2(entry.key, tx))) + .toList(); + + final pendingStatuses = {TransactionStatus.receiving, TransactionStatus.sending, TransactionStatus.selfsending}; + final confirmedStatuses = {TransactionStatus.received, TransactionStatus.sent, TransactionStatus.self}; + + final pending = + flatTxs.where((t) => pendingStatuses.contains(TransactionUtil.getStatus(t.item2))).toList() + ..sort((a, b) => b.item2.timestamp.compareTo(a.item2.timestamp)); + final confirmed = + flatTxs.where((t) => confirmedStatuses.contains(TransactionUtil.getStatus(t.item2))).toList() + ..sort((a, b) => b.item2.timestamp.compareTo(a.item2.timestamp)); + + return [...pending, ...confirmed]; + } + + Widget _buildAnalysis() { + return SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.only(top: 36, left: 20, right: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShrinkAnimationButton( + defaultColor: CoconutColors.black, + onPressed: () { + CommonBottomSheets.showCustomHeightBottomSheet( + context: context, + heightRatio: 0.55, + child: AnalysisPeriodBottomSheet( + onSelected: (days) { + _viewModel.setAnalysisPeriod(days); + }, + onTransactionTypeSelected: (analysisTransactionType) { + _viewModel.setAnalysisTransactionType(analysisTransactionType); + }, + initialPeriodPreset: _viewModel.analysisPeriod, + initialAnalysisTransactionType: _viewModel.selectedAnalysisTransactionType, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _viewModel.analysisPeriod != 0 + ? t.wallet_home_screen.analysis_period( + days: _viewModel.analysisPeriod.toString(), + transaction_type: _viewModel.selectedAnalysisTransactionTypeName, + ) + : t.wallet_home_screen.analysis_period_cutsom( + transaction_type: _viewModel.selectedAnalysisTransactionTypeName, + ), + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + CoconutLayout.spacing_100w, + SvgPicture.asset( + 'assets/svg/caret-down.svg', + colorFilter: const ColorFilter.mode(CoconutColors.gray400, BlendMode.srcIn), + ), + ], + ), + ), + ), + if (_viewModel.recentTransactionAnalysis?.isEmpty == true || + _viewModel.recentTransactionAnalysis == null) ...[ + // 분석에 필요한 거래가 없을 때 + Container( + padding: const EdgeInsets.only(left: 20, right: 14, top: 20, bottom: 20), + decoration: const BoxDecoration( + color: CoconutColors.gray800, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Center( + child: Text( + t.wallet_home_screen.empty_analysis_result, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + ), + ), + ] else if (_viewModel.recentTransactionAnalysis?.isEmpty == false) ...[ + Selector( + selector: (_, viewModel) => viewModel.isBtcUnit, + builder: (context, isBtcUnit, child) { + return Container( + width: MediaQuery.sizeOf(context).width, + padding: const EdgeInsets.only(top: 24, left: 16, right: 20, bottom: 20), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: CoconutColors.gray800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + children: [ + Text( + _viewModel.recentTransactionAnalysis!.titleString, + style: CoconutTypography.body2_14_NumberBold, + ), + Text( + _viewModel.recentTransactionAnalysis!.totalAmountResult, + style: CoconutTypography.body2_14, + ), + ], + ), + ), + ), + ], + ), + CoconutLayout.spacing_300h, + Text( + _viewModel.recentTransactionAnalysis!.subtitleString, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + if (_viewModel.recentTransactionAnalysis!.receivedTxs.isNotEmpty && + _viewModel.selectedAnalysisTransactionType != AnalysisTransactionType.onlySent) ...[ + _buildAnalysisTransactionRow( + 'assets/svg/tx-received.svg', + _viewModel.recentTransactionAnalysis!.receivedTxs.length, + _viewModel.recentTransactionAnalysis!.receivedAmount, + TransactionType.received, + isBtcUnit, + ), + ], + if (_viewModel.recentTransactionAnalysis!.sentTxs.isNotEmpty && + _viewModel.selectedAnalysisTransactionType != AnalysisTransactionType.onlyReceived) ...[ + _buildAnalysisTransactionRow( + 'assets/svg/tx-sent.svg', + _viewModel.recentTransactionAnalysis!.sentTxs.length, + _viewModel.recentTransactionAnalysis!.sentAmount, + TransactionType.sent, + isBtcUnit, + ), + ], + if (_viewModel.recentTransactionAnalysis!.selfTxs.isNotEmpty && + _viewModel.selectedAnalysisTransactionType != AnalysisTransactionType.onlyReceived) ...[ + _buildAnalysisTransactionRow( + 'assets/svg/tx-self.svg', + _viewModel.recentTransactionAnalysis!.selfTxs.length, + _viewModel.recentTransactionAnalysis!.selfAmount, + TransactionType.self, + isBtcUnit, + ), + ], + ], + ), + ); + }, + ), + ], + ], + ), + ), + ); + } + + Widget _buildAnalysisTransactionRow(String assetPath, int count, int amount, TransactionType type, bool isBtcUnit) { + String getIconPath() { + switch (type) { + case TransactionType.received: + return 'assets/svg/tx-received.svg'; + case TransactionType.sent: + return 'assets/svg/tx-sent.svg'; + case TransactionType.self: + return 'assets/svg/tx-self.svg'; + default: + return 'assets/svg/tx-received.svg'; + } + } + + final String amountString = + isBtcUnit + ? BitcoinUnit.btc.displayBitcoinAmount(amount, withUnit: true) + : BitcoinUnit.sats.displayBitcoinAmount(amount, withUnit: true); + final bool isReceived = type == TransactionType.received; + final String prefix = isReceived ? '+' : ''; + return Column( + children: [ + if (type != TransactionType.self) ...[CoconutLayout.spacing_400h] else ...[CoconutLayout.spacing_200h], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + SvgPicture.asset(getIconPath(), fit: BoxFit.fill, width: 24, height: 24), + CoconutLayout.spacing_100w, + Text( + t.wallet_home_screen.count(count: count.toString()), + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400), + ), + ], + ), + if (type == TransactionType.self) ...[ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('$prefix$amountString', style: CoconutTypography.body2_14_Number), + Text( + t.fee, + style: CoconutTypography.body3_12.setColor(CoconutColors.gray400).copyWith(height: 1.4), + ), + ], + ), + ), + ), + ] else ...[ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text('$prefix$amountString', style: CoconutTypography.body2_14_Number), + ), + ), + ], + ], + ), + ], + ); + } + + Widget _buildAnalysisSkeleton() { + return SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.only(top: 36, left: 20, right: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Shimmer.fromColors( + baseColor: CoconutColors.gray800, + highlightColor: CoconutColors.gray750, + child: Container( + margin: const EdgeInsets.only(top: 8, left: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CoconutStyles.radius_200), + color: CoconutColors.gray800, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('최근 30일 • 보내기', style: CoconutTypography.body3_12.setColor(CoconutColors.gray400)), + CoconutLayout.spacing_100w, + SvgPicture.asset( + 'assets/svg/caret-down.svg', + colorFilter: const ColorFilter.mode(CoconutColors.gray400, BlendMode.srcIn), + ), + ], + ), + ), + ), + Shimmer.fromColors( + baseColor: CoconutColors.gray800, + highlightColor: CoconutColors.gray750, + child: Container( + width: MediaQuery.sizeOf(context).width, + margin: const EdgeInsets.only(top: 8), + height: 90, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(CoconutStyles.radius_200), + color: CoconutColors.gray800, + ), + child: const Text('', style: CoconutTypography.body2_14), + ), + ), + ], + ), + ), + ); + } + void _goToScannerScreen(WalletImportSource walletImportSource) async { Navigator.pop(context); final ResultOfSyncFromVault? scanResult = @@ -1250,4 +1905,26 @@ class _WalletHomeScreenState extends State with TickerProvider void _setPulldownMenuVisiblility(bool value) { _isDropdownMenuVisible.value = value; } + + void _scrollToIndicator(int index) { + if (!_pageIndicatorController.hasClients) return; + + // 실제 화면 너비를 기반으로 보이는 점 개수 계산 + final screenWidth = MediaQuery.of(context).size.width; + const double dotWidth = 16.0; // 8px + 8px margin + final int visibleDots = (screenWidth / dotWidth).floor(); + + // 현재 페이지가 화면 중앙에 오도록 스크롤 + final double targetOffset = (index - visibleDots ~/ 2) * dotWidth; + final double maxOffset = _pageIndicatorController.position.maxScrollExtent; + final double minOffset = _pageIndicatorController.position.minScrollExtent; + + final double clampedOffset = targetOffset.clamp(minOffset, maxOffset); + + _pageIndicatorController.animateTo( + clampedOffset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } } diff --git a/lib/screens/home/wallet_item_setting_bottom_sheet.dart b/lib/screens/home/wallet_item_setting_bottom_sheet.dart index 4706e34c5..8f2bfe4f5 100644 --- a/lib/screens/home/wallet_item_setting_bottom_sheet.dart +++ b/lib/screens/home/wallet_item_setting_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/screens/home/wallet_list_glossary_bottom_sheet.dart b/lib/screens/home/wallet_list_glossary_bottom_sheet.dart index aa13cd5b7..1e4339cc8 100644 --- a/lib/screens/home/wallet_list_glossary_bottom_sheet.dart +++ b/lib/screens/home/wallet_list_glossary_bottom_sheet.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/constants/external_links.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/widgets/overlays/common_bottom_sheets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/lib/screens/home/wallet_list_screen.dart b/lib/screens/home/wallet_list_screen.dart index 2b413f940..e5e57abce 100644 --- a/lib/screens/home/wallet_list_screen.dart +++ b/lib/screens/home/wallet_list_screen.dart @@ -5,7 +5,7 @@ import 'package:coconut_wallet/model/wallet/balance.dart'; import 'package:coconut_wallet/providers/auth_provider.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/view_model/home/wallet_add_scanner_view_model.dart'; import 'package:coconut_wallet/screens/common/pin_check_screen.dart'; @@ -310,11 +310,17 @@ class _WalletListScreenState extends State with TickerProvider // 전체 총액 Row( children: [ - AnimatedBalance( - prevValue: prevTotalBalance, - value: totalBalance, - currentUnit: isBtcUnit ? BitcoinUnit.btc : BitcoinUnit.sats, - textStyle: CoconutTypography.heading4_18_NumberBold, + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: AnimatedBalance( + prevValue: prevTotalBalance, + value: totalBalance, + currentUnit: isBtcUnit ? BitcoinUnit.btc : BitcoinUnit.sats, + textStyle: CoconutTypography.heading4_18_NumberBold, + ), + ), ), CoconutLayout.spacing_100w, Text(isBtcUnit ? t.btc : t.sats, style: CoconutTypography.heading4_18_NumberBold), diff --git a/lib/screens/send/broadcasting_screen.dart b/lib/screens/send/broadcasting_screen.dart index 680fa5c35..ad74efb21 100644 --- a/lib/screens/send/broadcasting_screen.dart +++ b/lib/screens/send/broadcasting_screen.dart @@ -6,7 +6,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/error/app_error.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; diff --git a/lib/screens/send/fee_selection_screen.dart b/lib/screens/send/fee_selection_screen.dart index 68cb5ecf8..43c06d6e0 100644 --- a/lib/screens/send/fee_selection_screen.dart +++ b/lib/screens/send/fee_selection_screen.dart @@ -7,7 +7,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/error/app_error.dart'; import 'package:coconut_wallet/model/send/fee_info.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/screens/common/text_field_bottom_sheet.dart'; import 'package:coconut_wallet/utils/balance_format_util.dart'; import 'package:coconut_wallet/utils/text_field_filter_util.dart'; diff --git a/lib/screens/send/refactor/select_wallet_bottom_sheet.dart b/lib/screens/send/refactor/select_wallet_bottom_sheet.dart index b1aecca27..0379677cc 100644 --- a/lib/screens/send/refactor/select_wallet_bottom_sheet.dart +++ b/lib/screens/send/refactor/select_wallet_bottom_sheet.dart @@ -7,7 +7,7 @@ import 'package:coconut_wallet/model/wallet/balance.dart'; import 'package:coconut_wallet/model/wallet/multisig_signer.dart'; import 'package:coconut_wallet/model/wallet/multisig_wallet_list_item.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/utils/colors_util.dart'; import 'package:coconut_wallet/utils/wallet_util.dart'; diff --git a/lib/screens/send/refactor/send_screen.dart b/lib/screens/send/refactor/send_screen.dart index 58a0805f9..20b2684ff 100644 --- a/lib/screens/send/refactor/send_screen.dart +++ b/lib/screens/send/refactor/send_screen.dart @@ -4,7 +4,7 @@ import 'package:coconut_wallet/extensions/string_extensions.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/view_model/send/refactor/send_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; diff --git a/lib/screens/send/refactor/utxo_selection_screen.dart b/lib/screens/send/refactor/utxo_selection_screen.dart index 4e962d39a..a7db27e82 100644 --- a/lib/screens/send/refactor/utxo_selection_screen.dart +++ b/lib/screens/send/refactor/utxo_selection_screen.dart @@ -4,7 +4,7 @@ import 'package:coconut_wallet/enums/utxo_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/utxo/utxo_state.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; import 'package:coconut_wallet/providers/view_model/send/refactor/utxo_selection_view_model.dart'; diff --git a/lib/screens/send/send_confirm_screen.dart b/lib/screens/send/send_confirm_screen.dart index 26eac98da..783c05704 100644 --- a/lib/screens/send/send_confirm_screen.dart +++ b/lib/screens/send/send_confirm_screen.dart @@ -1,7 +1,7 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/view_model/send/send_confirm_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; diff --git a/lib/screens/send/unsigned_transaction_qr_screen.dart b/lib/screens/send/unsigned_transaction_qr_screen.dart index 4e7baadd3..ae829f846 100644 --- a/lib/screens/send/unsigned_transaction_qr_screen.dart +++ b/lib/screens/send/unsigned_transaction_qr_screen.dart @@ -4,7 +4,7 @@ import 'dart:convert'; import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/enums/wallet_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/styles.dart'; import 'package:coconut_wallet/utils/bb_qr/bb_qr_encoder.dart'; diff --git a/lib/screens/settings/electrum_server_screen.dart b/lib/screens/settings/electrum_server_screen.dart index eec3dd7a6..ea0f4db9a 100644 --- a/lib/screens/settings/electrum_server_screen.dart +++ b/lib/screens/settings/electrum_server_screen.dart @@ -4,7 +4,7 @@ import 'package:coconut_wallet/enums/electrum_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/node/electrum_server.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/settings/electrum_server_view_model.dart'; import 'package:coconut_wallet/utils/icons_util.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; diff --git a/lib/screens/settings/fake_balance_bottom_sheet.dart b/lib/screens/settings/fake_balance_bottom_sheet.dart deleted file mode 100644 index 9b1cf5c2d..000000000 --- a/lib/screens/settings/fake_balance_bottom_sheet.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:coconut_design_system/coconut_design_system.dart'; -import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; -import 'package:coconut_wallet/providers/wallet_provider.dart'; -import 'package:coconut_wallet/utils/balance_format_util.dart'; -import 'package:coconut_wallet/widgets/overlays/coconut_loading_overlay.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -enum FakeBalanceInputError { - none, - exceedsTotalSupply, // 2100만 BTC를 초과하는 경우 -} - -class FakeBalanceBottomSheet extends StatefulWidget { - const FakeBalanceBottomSheet({super.key}); - - @override - State createState() => _FakeBalanceBottomSheetState(); -} - -class _FakeBalanceBottomSheetState extends State { - final TextEditingController _textEditingController = TextEditingController(); - final FocusNode _textFieldFocusNode = FocusNode(); - double? _fakeBalanceTotalBtc; - FakeBalanceInputError _inputError = FakeBalanceInputError.none; - - late final WalletProvider _walletProvider; - late final PreferenceProvider _preferenceProvider; - final int _maximumAmount = 21000000; - final int _maxInputLength = 17; // 21000000.00000000 - - bool isLoading = false; - late bool _isFakeBalanceActive; - - @override - void initState() { - super.initState(); - _preferenceProvider = context.read(); - _fakeBalanceTotalBtc = - _preferenceProvider.fakeBalanceTotalAmount != null - ? UnitUtil.convertSatoshiToBitcoin(_preferenceProvider.fakeBalanceTotalAmount!) - : null; - _isFakeBalanceActive = _preferenceProvider.isFakeBalanceActive; - - _walletProvider = Provider.of(context, listen: false); - if (_fakeBalanceTotalBtc != null) { - if (_fakeBalanceTotalBtc == 0) { - // 0일 때 - _textEditingController.text = '0'; - } else if (_fakeBalanceTotalBtc! % 1 == 0) { - // 정수일 때 - _textEditingController.text = _fakeBalanceTotalBtc.toString().split('.')[0]; - } else { - // 아주 작은 소수일 때 e-8로 표시되는 경우가 있음 -> toStringAsFixed(8)로 8자리까지 표시 후 뒤에 0이 있으면 제거 - _textEditingController.text = _fakeBalanceTotalBtc!.toStringAsFixed(8).replaceFirst(RegExp(r'\.?0+$'), ''); - } - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - _textEditingController.addListener(() { - double? input; - if (_textEditingController.text.isEmpty) { - _fakeBalanceTotalBtc = null; - } - - if (_textEditingController.text.length > 1 && - _textEditingController.text[0] == '0' && - _textEditingController.text[1] == '0' && - !_textEditingController.text.contains('.')) { - // 정수 자리의 첫번째가 0인 경우 0의 추가 입력을 막음 - _textEditingController.text = _textEditingController.text.substring(1); - _textEditingController.selection = TextSelection.fromPosition( - TextPosition(offset: _textEditingController.text.length), - ); - } - - try { - input = double.parse(_textEditingController.text); - } catch (e) { - debugPrint(e.toString()); - } - - setState(() { - _fakeBalanceTotalBtc = input; - if (_fakeBalanceTotalBtc == null) { - _inputError = FakeBalanceInputError.none; - } else { - if (_fakeBalanceTotalBtc! > _maximumAmount) { - _inputError = FakeBalanceInputError.exceedsTotalSupply; - } else { - _inputError = FakeBalanceInputError.none; - } - } - }); - }); - }); - } - - @override - void dispose() { - _textEditingController.dispose(); - _textFieldFocusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - FocusScope.of(context).unfocus(); - }, - child: Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: CoconutColors.black, - appBar: CoconutAppBar.build( - title: t.settings_screen.fake_balance.fake_balance_setting, - context: context, - isBottom: true, - ), - body: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 25), - child: Column( - children: [ - Expanded( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.settings_screen.fake_balance.fake_balance_display, - style: CoconutTypography.body2_14_Bold.setColor(CoconutColors.white), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: CoconutSwitch( - isOn: _isFakeBalanceActive, - activeColor: CoconutColors.gray100, - trackColor: CoconutColors.gray600, - thumbColor: CoconutColors.gray800, - onChanged: (value) { - setState(() { - _isFakeBalanceActive = value; - }); - }, - ), - ), - ], - ), - CoconutLayout.spacing_600h, - Visibility( - visible: _isFakeBalanceActive, - child: CoconutTextField( - textInputType: const TextInputType.numberWithOptions(decimal: true), - textInputFormatter: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,8}'))], - placeholderText: - _fakeBalanceTotalBtc != null - ? '' - : t.settings_screen.fake_balance.fake_balance_input_placeholder, - descriptionText: - _textFieldFocusNode.hasFocus - ? ' ${t.settings_screen.fake_balance.fake_balance_input_description}' - : '', - suffix: - _textEditingController.text.isNotEmpty - ? Padding( - padding: const EdgeInsets.only(right: 16), - child: Text(t.btc, style: CoconutTypography.body1_16), - ) - : null, - isLengthVisible: false, - controller: _textEditingController, - focusNode: _textFieldFocusNode, - onChanged: (text) {}, - backgroundColor: CoconutColors.white.withValues(alpha: 0.15), - errorColor: CoconutColors.hotPink, - placeholderColor: CoconutColors.gray700, - activeColor: CoconutColors.white, - cursorColor: CoconutColors.white, - maxLength: _maxInputLength, - errorText: - _inputError == FakeBalanceInputError.exceedsTotalSupply - ? ' ${t.settings_screen.fake_balance.fake_balance_input_exceeds_error}' - : '', - isError: _inputError != FakeBalanceInputError.none, - maxLines: 1, - ), - ), - ], - ), - ), - CoconutButton( - backgroundColor: CoconutColors.white, - isActive: _shouldEnableCompleteButton(), - onPressed: () async { - FocusScope.of(context).unfocus(); - setState(() { - isLoading = true; - }); - - _onComplete(); - if (mounted) { - Navigator.pop(context); - } - }, - text: t.complete, - ), - CoconutLayout.spacing_800h, - ], - ), - ), - if (isLoading) const CoconutLoadingOverlay(), - ], - ), - ), - ); - } - - bool _shouldEnableCompleteButton() { - if (isLoading) return false; - if (_textEditingController.text.isEmpty) return false; - - final text = _textEditingController.text; - final isToggleChanged = _isFakeBalanceActive != _preferenceProvider.isFakeBalanceActive; - - if (_preferenceProvider.fakeBalanceTotalAmount == null) { - return isToggleChanged; - } - // satoshi를 BTC로 변환 - double fakeBalanceTotalAmount = (_preferenceProvider.fakeBalanceTotalAmount ?? 0) / 100000000; - - if (_isFakeBalanceActive) { - // text가 "0"일 때와 fakeBalanceTotalAmount가 0일 때를 동일하게 처리 - // text를 double로 파싱해서 비교 - final textAsDouble = double.tryParse(text) ?? 0; - final isTextChanged = textAsDouble != fakeBalanceTotalAmount; - - if (text.isEmpty || !isTextChanged) return false; - - final parsed = double.tryParse(text); - if (parsed != 0 && _inputError != FakeBalanceInputError.none) return false; - return true; - } else { - return isToggleChanged; - } - } - - void _onComplete() async { - final wallets = _walletProvider.walletItemList; - if (!_isFakeBalanceActive) { - await _preferenceProvider.toggleFakeBalanceActivation(false); - return; - } - if (_fakeBalanceTotalBtc == null || wallets.isEmpty) return; - - // fake balance 토글 상태 변경 시 상태 업데이트 - if (_preferenceProvider.isFakeBalanceActive != _isFakeBalanceActive) { - await _preferenceProvider.toggleFakeBalanceActivation(_isFakeBalanceActive); - } - - final isFakeBalanceActive = _preferenceProvider.isFakeBalanceActive; - _preferenceProvider.distributeFakeBalance( - wallets, - isFakeBalanceActive: isFakeBalanceActive, - fakeBalanceTotalSats: UnitUtil.convertBitcoinToSatoshi(_fakeBalanceTotalBtc!.toDouble()).toDouble(), - ); - } -} diff --git a/lib/screens/settings/fiat_bottom_sheet.dart b/lib/screens/settings/fiat_bottom_sheet.dart index 2e3482617..894231f9d 100644 --- a/lib/screens/settings/fiat_bottom_sheet.dart +++ b/lib/screens/settings/fiat_bottom_sheet.dart @@ -1,6 +1,6 @@ import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; import 'package:coconut_wallet/widgets/bottom_sheet/selection_bottom_sheet.dart'; import 'package:flutter/material.dart'; diff --git a/lib/screens/settings/language_bottom_sheet.dart b/lib/screens/settings/language_bottom_sheet.dart index 2f290099f..3028db004 100644 --- a/lib/screens/settings/language_bottom_sheet.dart +++ b/lib/screens/settings/language_bottom_sheet.dart @@ -1,5 +1,5 @@ import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/utils/vibration_util.dart'; import 'package:coconut_wallet/widgets/bottom_sheet/selection_bottom_sheet.dart'; import 'package:flutter/material.dart'; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 6de466c40..61602ec48 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -3,9 +3,8 @@ import 'package:coconut_lib/coconut_lib.dart'; import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/providers/auth_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/settings/settings_view_model.dart'; -import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/repository/realm/realm_manager.dart'; import 'package:coconut_wallet/screens/common/pin_check_screen.dart'; import 'package:coconut_wallet/screens/settings/pin_setting_screen.dart'; @@ -15,8 +14,6 @@ import 'package:coconut_wallet/screens/settings/language_bottom_sheet.dart'; import 'package:coconut_wallet/screens/settings/fiat_bottom_sheet.dart'; import 'package:coconut_wallet/widgets/button/button_group.dart'; import 'package:coconut_wallet/widgets/custom_loading_overlay.dart'; -import 'package:coconut_wallet/screens/settings/fake_balance_bottom_sheet.dart'; -import 'package:coconut_wallet/widgets/button/multi_button.dart'; import 'package:coconut_wallet/widgets/overlays/common_bottom_sheets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -110,51 +107,32 @@ class _SettingsScreen extends State { ], ), - // 홈 잔액 숨기기 + 가짜 잔액 설정 - // TODO: ButtonGroup과 MultiButton 통합 혹은 usage 구별이 필요함 - if (context.read().walletItemList.isNotEmpty) ...[ - CoconutLayout.spacing_200h, - MultiButton( - children: [ - SingleButton( - title: t.settings_screen.hide_balance, - rightElement: _buildSwitch( - isOn: viewModel.isBalanceHidden, - onChanged: (value) { - viewModel.changeIsBalanceHidden(value); - }, - ), - ), - SingleButton( - enableShrinkAnim: true, - title: t.settings_screen.fake_balance.fake_balance_setting, - onPressed: () async { - CommonBottomSheets.showCustomHeightBottomSheet( - context: context, - heightRatio: 0.5, - child: const FakeBalanceBottomSheet(), - ); - }, - ), - ], - ), - // ButtonGroup(buttons: [ - // SingleButton( - // title: t.settings_screen.hide_balance, - // rightElement: _buildSwitch( - // isOn: viewModel.isBalanceHidden, - // onChanged: (value) { - // viewModel.changeIsBalanceHidden(value); - // })), - // _buildAnimatedButton( - // title: t.settings_screen.fake_balance.fake_balance_setting, - // onPressed: () async { - // CommonBottomSheets.showBottomSheet_50( - // context: context, child: const FakeBalanceBottomSheet()); - // }, - // ), - // ]), - ], + // if (context.read().walletItemList.isNotEmpty) ...[ + // CoconutLayout.spacing_200h, + // MultiButton( + // children: [ + // SingleButton( + // title: t.settings_screen.hide_balance, + // rightElement: CupertinoSwitch( + // value: viewModel.isBalanceHidden, + // activeColor: CoconutColors.gray100, + // trackColor: CoconutColors.gray600, + // thumbColor: CoconutColors.gray800, + // onChanged: (value) { + // viewModel.changeIsBalanceHidden(value); + // }), + // ), + // if (viewModel.isBalanceHidden) + // SingleButton( + // title: t.settings_screen.fake_balance.fake_balance_setting, + // onPressed: () async { + // CommonBottomSheets.showBottomSheet_50( + // context: context, child: const FakeBalanceBottomSheet()); + // }, + // ), + // ], + // ), + // ], CoconutLayout.spacing_400h, // 단위 diff --git a/lib/screens/settings/unit_bottom_sheet.dart b/lib/screens/settings/unit_bottom_sheet.dart index 15222f5b2..0a3ee98e3 100644 --- a/lib/screens/settings/unit_bottom_sheet.dart +++ b/lib/screens/settings/unit_bottom_sheet.dart @@ -1,5 +1,5 @@ import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/widgets/bottom_sheet/selection_bottom_sheet.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/screens/wallet_detail/address_list_screen.dart b/lib/screens/wallet_detail/address_list_screen.dart index 737ee435a..b3de5034b 100644 --- a/lib/screens/wallet_detail/address_list_screen.dart +++ b/lib/screens/wallet_detail/address_list_screen.dart @@ -6,7 +6,7 @@ import 'package:coconut_wallet/enums/fiat_enums.dart'; import 'package:coconut_wallet/constants/address.dart'; import 'package:coconut_wallet/model/wallet/wallet_address.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/wallet_detail/address_list_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/utils/logger.dart'; diff --git a/lib/screens/wallet_detail/address_search_screen.dart b/lib/screens/wallet_detail/address_search_screen.dart index 3f10561fa..1301f9b4c 100644 --- a/lib/screens/wallet_detail/address_search_screen.dart +++ b/lib/screens/wallet_detail/address_search_screen.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/wallet/wallet_address.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/view_model/wallet_detail/address_search_view_model.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/screens/common/qr_with_copy_text_screen.dart'; diff --git a/lib/screens/wallet_detail/transaction_detail_screen.dart b/lib/screens/wallet_detail/transaction_detail_screen.dart index f592b5c4c..6f26b6d77 100644 --- a/lib/screens/wallet_detail/transaction_detail_screen.dart +++ b/lib/screens/wallet_detail/transaction_detail_screen.dart @@ -8,7 +8,7 @@ import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/view_model/wallet_detail/transaction_detail_view_model.dart'; diff --git a/lib/screens/wallet_detail/transaction_fee_bumping_screen.dart b/lib/screens/wallet_detail/transaction_fee_bumping_screen.dart index 22f2c6fb2..876c19f33 100644 --- a/lib/screens/wallet_detail/transaction_fee_bumping_screen.dart +++ b/lib/screens/wallet_detail/transaction_fee_bumping_screen.dart @@ -5,7 +5,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/view_model/wallet_detail/fee_bumping_view_model.dart'; diff --git a/lib/screens/wallet_detail/utxo_detail_screen.dart b/lib/screens/wallet_detail/utxo_detail_screen.dart index b9ee3d258..662f6149f 100644 --- a/lib/screens/wallet_detail/utxo_detail_screen.dart +++ b/lib/screens/wallet_detail/utxo_detail_screen.dart @@ -8,7 +8,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/utxo/utxo_tag.dart'; import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; import 'package:coconut_wallet/providers/view_model/wallet_detail/utxo_detail_view_model.dart'; diff --git a/lib/screens/wallet_detail/utxo_list_screen.dart b/lib/screens/wallet_detail/utxo_list_screen.dart index 47fb9829f..98137baa4 100644 --- a/lib/screens/wallet_detail/utxo_list_screen.dart +++ b/lib/screens/wallet_detail/utxo_list_screen.dart @@ -9,7 +9,7 @@ import 'package:coconut_wallet/model/utxo/utxo_tag.dart'; import 'package:coconut_wallet/model/wallet/balance.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; import 'package:coconut_wallet/providers/utxo_tag_provider.dart'; diff --git a/lib/screens/wallet_detail/wallet_detail_receive_address_screen.dart b/lib/screens/wallet_detail/wallet_detail_receive_address_screen.dart index dad64444e..3ace93343 100644 --- a/lib/screens/wallet_detail/wallet_detail_receive_address_screen.dart +++ b/lib/screens/wallet_detail/wallet_detail_receive_address_screen.dart @@ -3,7 +3,7 @@ import 'package:coconut_wallet/app_guard.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/wallet/wallet_address.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/wallet_provider.dart'; import 'package:coconut_wallet/screens/send/refactor/select_wallet_bottom_sheet.dart'; import 'package:flutter/material.dart'; diff --git a/lib/screens/wallet_detail/wallet_detail_screen.dart b/lib/screens/wallet_detail/wallet_detail_screen.dart index 24149accb..82c81c7d6 100644 --- a/lib/screens/wallet_detail/wallet_detail_screen.dart +++ b/lib/screens/wallet_detail/wallet_detail_screen.dart @@ -11,7 +11,7 @@ import 'package:coconut_wallet/model/wallet/balance.dart'; import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/providers/connectivity_provider.dart'; import 'package:coconut_wallet/providers/node_provider/node_provider.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/providers/send_info_provider.dart'; import 'package:coconut_wallet/providers/transaction_provider.dart'; import 'package:coconut_wallet/providers/price_provider.dart'; diff --git a/lib/utils/alert_util.dart b/lib/utils/alert_util.dart index f658cdf82..0d7a17dfa 100644 --- a/lib/utils/alert_util.dart +++ b/lib/utils/alert_util.dart @@ -1,6 +1,6 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:flutter/cupertino.dart'; import 'package:coconut_wallet/styles.dart'; import 'package:provider/provider.dart'; diff --git a/lib/utils/transaction_util.dart b/lib/utils/transaction_util.dart index f7799438f..be2b1bfd5 100644 --- a/lib/utils/transaction_util.dart +++ b/lib/utils/transaction_util.dart @@ -7,7 +7,7 @@ import 'package:coconut_wallet/localization/strings.g.dart'; import 'package:coconut_wallet/model/utxo/utxo_state.dart'; import 'package:coconut_wallet/model/wallet/transaction_record.dart'; import 'package:coconut_wallet/model/wallet/wallet_list_item_base.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/animated_dots_text.dart b/lib/widgets/animated_dots_text.dart new file mode 100644 index 000000000..079395a1c --- /dev/null +++ b/lib/widgets/animated_dots_text.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class AnimatedDotsText extends StatefulWidget { + final String text; + final TextStyle style; + + const AnimatedDotsText({super.key, required this.text, required this.style}); + + @override + State createState() => _AnimatedDotsTextState(); +} + +class _AnimatedDotsTextState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final dotsCount = ((_controller.value * 4) % 4).floor(); + final dots = '.' * dotsCount; + return Text('${widget.text}$dots', style: widget.style); + }, + ); + } +} diff --git a/lib/widgets/animated_qr/coconut_qr_scanner.dart b/lib/widgets/animated_qr/coconut_qr_scanner.dart index 8695c9ee2..9e5fd4ad2 100644 --- a/lib/widgets/animated_qr/coconut_qr_scanner.dart +++ b/lib/widgets/animated_qr/coconut_qr_scanner.dart @@ -1,6 +1,6 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/utils/app_settings_util.dart'; import 'package:coconut_wallet/utils/logger.dart'; import 'package:coconut_wallet/widgets/animated_qr/scan_data_handler/i_fragmented_qr_scan_data_handler.dart'; diff --git a/lib/widgets/body/address_qr_scanner_body.dart b/lib/widgets/body/address_qr_scanner_body.dart index ee8d25f4a..34755e7ce 100644 --- a/lib/widgets/body/address_qr_scanner_body.dart +++ b/lib/widgets/body/address_qr_scanner_body.dart @@ -1,6 +1,6 @@ import 'package:coconut_design_system/coconut_design_system.dart'; import 'package:coconut_wallet/localization/strings.g.dart'; -import 'package:coconut_wallet/providers/preference_provider.dart'; +import 'package:coconut_wallet/providers/preferences/preference_provider.dart'; import 'package:coconut_wallet/utils/app_settings_util.dart'; import 'package:coconut_wallet/widgets/dialog.dart'; import 'package:coconut_wallet/widgets/overlays/scanner_overlay.dart'; diff --git a/lib/widgets/button/fixed_bottom_button.dart b/lib/widgets/button/fixed_bottom_button.dart index cf5efefa2..317afc28f 100644 --- a/lib/widgets/button/fixed_bottom_button.dart +++ b/lib/widgets/button/fixed_bottom_button.dart @@ -19,6 +19,8 @@ class FixedBottomButton extends StatefulWidget { this.subWidget, this.backgroundColor = CoconutColors.primary, this.pressedBackgroundColor, + this.gradientKey, + this.buttonKey, }); final Function onButtonClicked; @@ -33,6 +35,8 @@ class FixedBottomButton extends StatefulWidget { final Widget? subWidget; final Color backgroundColor; final Color? pressedBackgroundColor; + final Key? gradientKey; + final Key? buttonKey; @override State createState() => _FixedBottomButtonState(); @@ -48,6 +52,7 @@ class _FixedBottomButtonState extends State { children: [ if (widget.showGradient) Positioned( + key: widget.gradientKey, left: 0, right: 0, bottom: keyboardHeight, @@ -71,6 +76,7 @@ class _FixedBottomButtonState extends State { right: widget.horizontalPadding, bottom: keyboardHeight + widget.bottomPadding, child: Column( + key: widget.buttonKey, children: [ widget.subWidget ?? Container(), CoconutLayout.spacing_300h, diff --git a/lib/widgets/button/multi_button.dart b/lib/widgets/button/multi_button.dart index d0692055d..4e5dbe48b 100644 --- a/lib/widgets/button/multi_button.dart +++ b/lib/widgets/button/multi_button.dart @@ -6,12 +6,14 @@ class MultiButton extends StatefulWidget { final List children; final int animationDuration; final Color backgroundColor; + final bool showDivider; const MultiButton({ super.key, required this.children, this.animationDuration = 100, this.backgroundColor = CoconutColors.gray800, + this.showDivider = true, }); @override diff --git a/lib/widgets/button/single_button.dart b/lib/widgets/button/single_button.dart index 42846adab..43a875e19 100644 --- a/lib/widgets/button/single_button.dart +++ b/lib/widgets/button/single_button.dart @@ -41,7 +41,11 @@ class SingleButton extends StatelessWidget { final Widget? leftElement; final SingleButtonPosition buttonPosition; final TextStyle? subtitleStyle; + final Color backgroundColor; + final double? betweenGap; + final EdgeInsets? customPadding; final bool enableShrinkAnim; + final bool isVerticalSubtitle; final double animationEndValue; const SingleButton({ @@ -53,9 +57,13 @@ class SingleButton extends StatelessWidget { this.rightElement, this.leftElement, this.buttonPosition = SingleButtonPosition.none, + this.subtitleStyle = CoconutTypography.body3_12, + this.backgroundColor = CoconutColors.gray800, + this.betweenGap = 0, + this.customPadding, this.enableShrinkAnim = false, + this.isVerticalSubtitle = false, this.animationEndValue = 0.95, - this.subtitleStyle, }); @override @@ -65,26 +73,31 @@ class SingleButton extends StatelessWidget { return enableShrinkAnim ? ShrinkAnimationButton( onPressed: onPressed ?? () {}, - defaultColor: CoconutColors.gray800, + defaultColor: backgroundColor, pressedColor: CoconutColors.gray750, borderRadius: 24, animationEndValue: animationEndValue, child: Container( decoration: BoxDecoration(borderRadius: buttonPosition.radius), - padding: buttonPosition.padding, + padding: getPadding(), child: buttonContent, ), ) : GestureDetector( onTap: onPressed, child: Container( - decoration: BoxDecoration(color: CoconutColors.gray800, borderRadius: buttonPosition.radius), - padding: buttonPosition.padding, + decoration: BoxDecoration(color: backgroundColor, borderRadius: buttonPosition.radius), + padding: getPadding(), child: buttonContent, ), ); } + /// padding을 계산하는 메서드 - 하위 클래스에서 오버라이드 가능 + EdgeInsets getPadding() { + return customPadding ?? buttonPosition.padding; + } + Widget _buildButtonContent() { return Row( children: [ @@ -97,10 +110,20 @@ class SingleButton extends StatelessWidget { fit: BoxFit.scaleDown, child: Text(title, style: CoconutTypography.body2_14_Bold.setColor(CoconutColors.white)), ), + if (isVerticalSubtitle) ...{ + CoconutLayout.spacing_100h, + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + subtitle!, + style: subtitleStyle ?? CoconutTypography.body3_12_Number.setColor(CoconutColors.gray400), + ), + ), + }, ], ), ), - if (subtitle != null) + if (subtitle != null && !isVerticalSubtitle) FittedBox( fit: BoxFit.scaleDown, child: Text( diff --git a/lib/widgets/overlays/common_bottom_sheets.dart b/lib/widgets/overlays/common_bottom_sheets.dart index 21a877ab1..0d04106f6 100644 --- a/lib/widgets/overlays/common_bottom_sheets.dart +++ b/lib/widgets/overlays/common_bottom_sheets.dart @@ -129,89 +129,128 @@ class CommonBottomSheets { required Widget Function(ScrollController) childBuilder, double minChildSize = 0.5, double maxChildSize = 0.9, + double? initialChildSize, + bool showDragHandle = true, + String? title, + String? subLabel, }) async { final draggableController = DraggableScrollableController(); bool isAnimating = false; + // initialChildSize가 지정되지 않은 경우에만 자동 계산 + final calculatedInitialSize = initialChildSize ?? (minChildSize <= 0.95 ? minChildSize + 0.05 : minChildSize); + + // initialChildSize가 maxChildSize를 초과하지 않도록 보장 + final finalInitialSize = calculatedInitialSize > maxChildSize ? maxChildSize : calculatedInitialSize; + return showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: CoconutColors.gray900, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)), + ), builder: (context) { - return DraggableScrollableSheet( - controller: draggableController, - initialChildSize: minChildSize, - minChildSize: minChildSize, - maxChildSize: maxChildSize, - expand: false, - builder: (context, scrollController) { - void handleDrag() { - if (isAnimating) return; - final extent = draggableController.size; - final targetExtent = - (extent - minChildSize).abs() < (extent - maxChildSize).abs() ? minChildSize + 0.01 : maxChildSize; + return ClipRRect( + borderRadius: const BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)), + child: DraggableScrollableSheet( + controller: draggableController, + initialChildSize: finalInitialSize, + minChildSize: minChildSize, + maxChildSize: maxChildSize, + expand: false, + builder: (context, scrollController) { + void handleDrag() { + if (isAnimating) return; + final extent = draggableController.size; + final targetExtent = + (extent - minChildSize).abs() < (extent - maxChildSize).abs() ? minChildSize + 0.01 : maxChildSize; - isAnimating = true; - draggableController - .animateTo(targetExtent, duration: const Duration(milliseconds: 50), curve: Curves.easeOut) - .whenComplete(() { - isAnimating = false; - }); - } + isAnimating = true; + draggableController + .animateTo(targetExtent, duration: const Duration(milliseconds: 50), curve: Curves.easeOut) + .whenComplete(() { + isAnimating = false; + }); + } - return NotificationListener( - onNotification: (notification) { - if (notification is ScrollEndNotification) { - handleDrag(); - return true; - } - return false; - }, - child: Column( - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onVerticalDragUpdate: (details) { - final delta = -details.primaryDelta! / MediaQuery.of(context).size.height; - draggableController.jumpTo(draggableController.size + delta); - }, - onVerticalDragEnd: (details) { - handleDrag(); - }, - onVerticalDragCancel: () { - handleDrag(); - }, - child: Container( - color: Colors.transparent, - padding: const EdgeInsets.symmetric(vertical: 8), - child: Center( - child: Container( - width: 55, - height: 4, - decoration: BoxDecoration( - color: CoconutColors.gray400, - borderRadius: BorderRadius.circular(4), + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollEndNotification) { + handleDrag(); + return true; + } + return false; + }, + child: Container( + color: CoconutColors.black, + child: Column( + children: [ + if (showDragHandle) + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) { + final delta = -details.primaryDelta! / MediaQuery.of(context).size.height; + draggableController.jumpTo(draggableController.size + delta); + }, + onVerticalDragEnd: (details) { + handleDrag(); + }, + onVerticalDragCancel: () { + handleDrag(); + }, + child: Container( + color: CoconutColors.black, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: Container( + width: 55, + height: 4, + decoration: BoxDecoration( + color: CoconutColors.gray400, + borderRadius: BorderRadius.circular(4), + ), + ), + ), ), ), + if (title != null) + GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: (details) { + final delta = -details.primaryDelta! / MediaQuery.of(context).size.height; + draggableController.jumpTo(draggableController.size + delta); + }, + onVerticalDragEnd: (details) { + handleDrag(); + }, + onVerticalDragCancel: () { + handleDrag(); + }, + child: CoconutAppBar.build( + title: title, + context: context, + onBackPressed: null, + subLabel: Text( + subLabel ?? '', + style: CoconutTypography.body3_12.setColor(CoconutColors.black), + ), + showSubLabel: subLabel != null, + isBottom: true, + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: childBuilder(scrollController), + ), ), - ), - ), - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), - topRight: Radius.circular(24), - ), - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: childBuilder(scrollController), - ), - ), + ], ), - ], - ), - ); - }, + ), + ); + }, + ), ); }, ); diff --git a/pubspec.yaml b/pubspec.yaml index 7b1af2c47..4c2bebbe7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,8 @@ environment: dependencies: flutter: sdk: flutter - + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 @@ -49,7 +50,7 @@ dependencies: flutter_svg: ^2.0.10+1 fluttertoast: ^8.2.12 loader_overlay: ^4.0.0 - carousel_slider: ^4.2.1 + carousel_slider: ^5.1.1 modal_bottom_sheet: ^3.0.0 connectivity_plus: ^6.1.3 url_launcher: ^6.2.6 @@ -270,6 +271,10 @@ flutter: - assets/svg/plus.svg - assets/svg/broom.svg - assets/svg/receipt.svg + - assets/svg/piggy-bank.svg + - assets/svg/wallet.svg + - assets/svg/transaction.svg + - assets/svg/analysis.svg - assets/svg/clip.svg - assets/svg/edit-outlined.svg - assets/svg/fee-rate/low.svg