diff --git a/.fvmrc b/.fvmrc index afd2043..efcdf3c 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.44.2" + "flutter": "3.44.3" } \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f6fd92e..98bf175 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,4 +5,4 @@ updates: - package-ecosystem: "pub" directory: "/" schedule: - interval: "weekly" + interval: "monthly" diff --git a/.github/versions.env b/.github/versions.env index eb39127..3334f39 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,2 +1,2 @@ -FLUTTER_VERSION=3.44.1 +FLUTTER_VERSION=3.44.3 CMAKE_VERSION=3.22.1 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a88cf85..a9df37e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.44.2" + "dart.flutterSdkPath": ".fvm/versions/3.44.3" } \ No newline at end of file diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 331f838..7e98971 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -8,7 +8,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1261.0) + aws-partitions (1.1262.0) aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -43,7 +43,7 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.5) + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -125,7 +125,7 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.103.0) + google-apis-androidpublisher_v3 (0.104.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -172,7 +172,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.9) + json (2.20.0) jwt (3.2.0) base64 logger (1.7.0) @@ -245,7 +245,7 @@ CHECKSUMS artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1261.0) sha256=89eda89781c1ea4e1be2a2dcdc72318c62c6171a01cdaeaa0cbecf3cea6f9fdc + aws-partitions (1.1262.0) sha256=77e9b1dd3e8f616673f2959d2fc4e762065f83a43140e2cb82274525afbaccf7 aws-sdk-core (3.252.0) sha256=09c042cbfc2acf2239441cc9b982ebab2a999bed2ef6bdc51849e7b3d6e48a1c aws-sdk-kms (1.129.0) sha256=363f548df321f4a4fcfd05523384e591060b400f8e65133ed7ef0793155a3343 aws-sdk-s3 (1.226.0) sha256=e599f431e006ec9b92c61ee0f14d3f658a1f6c8a1d623d2160be927ac958e2bf @@ -266,7 +266,7 @@ CHECKSUMS dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 - faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 @@ -283,7 +283,7 @@ CHECKSUMS fastlane (2.236.1) sha256=fb89e618e0f38636e487743622cf710ad722723f5d33a63f19e367f84d3770bc fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 - google-apis-androidpublisher_v3 (0.103.0) sha256=8075b9da398b201493ad1cd4074ff1a76ae67abf7eaad4242c0c75d22d61b559 + google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 @@ -298,7 +298,7 @@ CHECKSUMS http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 331f838..7e98971 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -8,7 +8,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1261.0) + aws-partitions (1.1262.0) aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -43,7 +43,7 @@ GEM dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.5) + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -125,7 +125,7 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.103.0) + google-apis-androidpublisher_v3 (0.104.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) @@ -172,7 +172,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.19.9) + json (2.20.0) jwt (3.2.0) base64 logger (1.7.0) @@ -245,7 +245,7 @@ CHECKSUMS artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1261.0) sha256=89eda89781c1ea4e1be2a2dcdc72318c62c6171a01cdaeaa0cbecf3cea6f9fdc + aws-partitions (1.1262.0) sha256=77e9b1dd3e8f616673f2959d2fc4e762065f83a43140e2cb82274525afbaccf7 aws-sdk-core (3.252.0) sha256=09c042cbfc2acf2239441cc9b982ebab2a999bed2ef6bdc51849e7b3d6e48a1c aws-sdk-kms (1.129.0) sha256=363f548df321f4a4fcfd05523384e591060b400f8e65133ed7ef0793155a3343 aws-sdk-s3 (1.226.0) sha256=e599f431e006ec9b92c61ee0f14d3f658a1f6c8a1d623d2160be927ac958e2bf @@ -266,7 +266,7 @@ CHECKSUMS dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 - faraday (1.10.5) sha256=b144f1d2b045652fa820b5f532723e1643cc28b93dae911d784e5c5f88e8f6ed + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 @@ -283,7 +283,7 @@ CHECKSUMS fastlane (2.236.1) sha256=fb89e618e0f38636e487743622cf710ad722723f5d33a63f19e367f84d3770bc fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 - google-apis-androidpublisher_v3 (0.103.0) sha256=8075b9da398b201493ad1cd4074ff1a76ae67abf7eaad4242c0c75d22d61b559 + google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 @@ -298,7 +298,7 @@ CHECKSUMS http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 diff --git a/ios/ParenW/Info.plist b/ios/ParenW/Info.plist index 36f8f25..1c9d3d3 100644 --- a/ios/ParenW/Info.plist +++ b/ios/ParenW/Info.plist @@ -3,9 +3,9 @@ CFBundleShortVersionString - 1.11.5 + 1.11.6 CFBundleVersion - 674 + 713 NSExtension NSExtensionPointIdentifier diff --git a/lib/components/bill_splitter_sheet.dart b/lib/components/bill_splitter_sheet.dart new file mode 100644 index 0000000..76d6aaa --- /dev/null +++ b/lib/components/bill_splitter_sheet.dart @@ -0,0 +1,715 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:paren/l10n/app_localizations_extension.dart'; +import 'package:paren/providers/extensions.dart'; +import 'package:paren/providers/paren.dart'; + +// --------------------------------------------------------------------------- +// Denomination data +// --------------------------------------------------------------------------- + +/// A single coin or banknote denomination. +class _Denomination { + final int valueMinorUnits; // in smallest currency unit (cents, kuruş, etc.) + final String label; + final bool isBill; + + const _Denomination({ + required this.valueMinorUnits, + required this.label, + required this.isBill, + }); +} + +/// Minor-unit scale per currency (i.e. how many minor units = 1 major unit). +const _minorUnitScale = { + 'jpy': 1, // ¥ has no subunit + 'usd': 100, + 'eur': 100, + 'try': 100, +}; + +/// Denominations for supported currencies, from largest to smallest. +const _denominations = >{ + 'jpy': [ + _Denomination(valueMinorUnits: 10000, label: '¥10,000', isBill: true), + _Denomination(valueMinorUnits: 5000, label: '¥5,000', isBill: true), + _Denomination(valueMinorUnits: 1000, label: '¥1,000', isBill: true), + _Denomination(valueMinorUnits: 500, label: '¥500', isBill: false), + _Denomination(valueMinorUnits: 100, label: '¥100', isBill: false), + _Denomination(valueMinorUnits: 50, label: '¥50', isBill: false), + _Denomination(valueMinorUnits: 10, label: '¥10', isBill: false), + _Denomination(valueMinorUnits: 5, label: '¥5', isBill: false), + _Denomination(valueMinorUnits: 1, label: '¥1', isBill: false), + ], + 'usd': [ + _Denomination(valueMinorUnits: 10000, label: '\$100', isBill: true), + _Denomination(valueMinorUnits: 5000, label: '\$50', isBill: true), + _Denomination(valueMinorUnits: 2000, label: '\$20', isBill: true), + _Denomination(valueMinorUnits: 1000, label: '\$10', isBill: true), + _Denomination(valueMinorUnits: 500, label: '\$5', isBill: true), + _Denomination(valueMinorUnits: 100, label: '\$1', isBill: true), + _Denomination(valueMinorUnits: 25, label: '25¢', isBill: false), + _Denomination(valueMinorUnits: 10, label: '10¢', isBill: false), + _Denomination(valueMinorUnits: 5, label: '5¢', isBill: false), + _Denomination(valueMinorUnits: 1, label: '1¢', isBill: false), + ], + 'eur': [ + _Denomination(valueMinorUnits: 50000, label: '€500', isBill: true), + _Denomination(valueMinorUnits: 20000, label: '€200', isBill: true), + _Denomination(valueMinorUnits: 10000, label: '€100', isBill: true), + _Denomination(valueMinorUnits: 5000, label: '€50', isBill: true), + _Denomination(valueMinorUnits: 2000, label: '€20', isBill: true), + _Denomination(valueMinorUnits: 1000, label: '€10', isBill: true), + _Denomination(valueMinorUnits: 500, label: '€5', isBill: true), + _Denomination(valueMinorUnits: 200, label: '€2', isBill: false), + _Denomination(valueMinorUnits: 100, label: '€1', isBill: false), + _Denomination(valueMinorUnits: 50, label: '50¢', isBill: false), + _Denomination(valueMinorUnits: 20, label: '20¢', isBill: false), + _Denomination(valueMinorUnits: 10, label: '10¢', isBill: false), + _Denomination(valueMinorUnits: 5, label: '5¢', isBill: false), + _Denomination(valueMinorUnits: 2, label: '2¢', isBill: false), + _Denomination(valueMinorUnits: 1, label: '1¢', isBill: false), + ], + 'try': [ + _Denomination(valueMinorUnits: 20000, label: '₺200', isBill: true), + _Denomination(valueMinorUnits: 10000, label: '₺100', isBill: true), + _Denomination(valueMinorUnits: 5000, label: '₺50', isBill: true), + _Denomination(valueMinorUnits: 2000, label: '₺20', isBill: true), + _Denomination(valueMinorUnits: 1000, label: '₺10', isBill: true), + _Denomination(valueMinorUnits: 500, label: '₺5', isBill: true), + _Denomination(valueMinorUnits: 100, label: '₺1', isBill: false), + _Denomination(valueMinorUnits: 50, label: '50kr', isBill: false), + _Denomination(valueMinorUnits: 25, label: '25kr', isBill: false), + _Denomination(valueMinorUnits: 10, label: '10kr', isBill: false), + _Denomination(valueMinorUnits: 5, label: '5kr', isBill: false), + _Denomination(valueMinorUnits: 1, label: '1kr', isBill: false), + ], +}; + +bool hasDenominations(String currencyId) => + _denominations.containsKey(currencyId.toLowerCase()); + +/// Returns a greedy denomination breakdown for [amountMinor] (in minor units). +List<({_Denomination denom, int count})> _breakdownFor( + String currencyId, + int amountMinor, +) { + var denoms = _denominations[currencyId.toLowerCase()]; + if (denoms == null || amountMinor <= 0) return []; + + var result = <({_Denomination denom, int count})>[]; + var remaining = amountMinor; + for (var d in denoms) { + if (remaining <= 0) break; + var count = remaining ~/ d.valueMinorUnits; + if (count > 0) { + result.add((denom: d, count: count)); + remaining -= count * d.valueMinorUnits; + } + } + return result; +} + +int _toMinor(double amount, String currencyId) { + var scale = _minorUnitScale[currencyId.toLowerCase()] ?? 100; + return (amount * scale).round(); +} + +// --------------------------------------------------------------------------- +// Top-level sheet widget +// --------------------------------------------------------------------------- + +class BillSplitterSheet extends StatefulWidget { + final double initialAmount; + final String fromCurrencyId; + final String toCurrencyId; + + const BillSplitterSheet({ + super.key, + required this.initialAmount, + required this.fromCurrencyId, + required this.toCurrencyId, + }); + + @override + State createState() => _BillSplitterSheetState(); +} + +class _BillSplitterSheetState extends State + with SingleTickerProviderStateMixin { + final Paren paren = Get.find(); + + late final TabController _tabController; + final _people = 2.obs; + + // Wallet counts: denomination index → count + late final Map _walletCounts; + + // The currency whose denominations are shown (fromCurrency when supported, + // otherwise toCurrency; falls back gracefully). + late String _denomCurrencyId; + + @override + void initState() { + super.initState(); + + // Decide which currency to use for the cash breakdown. + // Prefer the "from" side; if not supported, try "to"; else fallback. + _denomCurrencyId = hasDenominations(widget.fromCurrencyId) + ? widget.fromCurrencyId + : widget.toCurrencyId; + + var denomCount = + _denominations[_denomCurrencyId.toLowerCase()]?.length ?? 0; + _walletCounts = {for (var i = 0; i < denomCount; i++) i: 0.obs}; + + var hasCash = + hasDenominations(widget.fromCurrencyId) || + hasDenominations(widget.toCurrencyId); + _tabController = TabController(length: hasCash ? 3 : 1, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var l10n = context.l10n; + var hasCash = + hasDenominations(widget.fromCurrencyId) || + hasDenominations(widget.toCurrencyId); + + return Material( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Row( + children: [ + Icon( + Icons.calculate_outlined, + color: context.theme.colorScheme.primary, + ), + 8.w, + Text( + l10n.splitBill, + style: context.theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + if (hasCash) ...[ + TabBar( + controller: _tabController, + tabs: [ + Tab( + icon: const Icon(Icons.group_outlined), + text: l10n.splitBillTab, + ), + Tab( + icon: const Icon(Icons.paid_outlined), + text: l10n.denominationBreakdown, + ), + Tab( + icon: const Icon(Icons.wallet_outlined), + text: l10n.walletCheck, + ), + ], + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.55, + ), + child: TabBarView( + controller: _tabController, + children: [ + _SplitterTabView( + amount: widget.initialAmount, + fromCurrencyId: widget.fromCurrencyId, + toCurrencyId: widget.toCurrencyId, + people: _people, + ), + _BreakdownTabView( + amount: widget.initialAmount, + currencyId: _denomCurrencyId, + ), + _WalletTabView( + requiredAmount: widget.initialAmount, + currencyId: _denomCurrencyId, + walletCounts: _walletCounts, + ), + ], + ), + ), + ] else ...[ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.5, + ), + child: _SplitterTabView( + amount: widget.initialAmount, + fromCurrencyId: widget.fromCurrencyId, + toCurrencyId: widget.toCurrencyId, + people: _people, + ), + ), + ], + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Tab 1 — Bill Splitter +// --------------------------------------------------------------------------- + +class _SplitterTabView extends StatelessWidget { + final double amount; + final String fromCurrencyId; + final String toCurrencyId; + final RxInt people; + + const _SplitterTabView({ + required this.amount, + required this.fromCurrencyId, + required this.toCurrencyId, + required this.people, + }); + + @override + Widget build(BuildContext context) { + Paren paren = Get.find(); + var l10n = context.l10n; + + var fromFmt = NumberFormat.simpleCurrency( + name: fromCurrencyId.toUpperCase(), + locale: context.l10n.localeName, + ); + var toFmt = NumberFormat.simpleCurrency( + name: toCurrencyId.toUpperCase(), + locale: context.l10n.localeName, + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Obx(() { + var n = people.value; + var perPersonFrom = n > 0 ? amount / n : 0.0; + var perPersonTo = paren.convertValue( + perPersonFrom, + fromId: fromCurrencyId, + toId: toCurrencyId, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Total + _InfoRow( + label: l10n.total, + value: + '${fromFmt.format(amount)} ≈ ${toFmt.format(paren.convertValue(amount, fromId: fromCurrencyId, toId: toCurrencyId))}', + ), + 16.h, + // People stepper + Text( + l10n.numberOfPeople, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + 8.h, + Row( + children: [ + _StepperButton( + icon: Icons.remove, + onPressed: n > 1 ? () => people.value-- : null, + ), + Expanded( + child: Center( + child: Text( + '$n', + style: context.theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: context.theme.colorScheme.primary, + ), + ), + ), + ), + _StepperButton( + icon: Icons.add, + onPressed: n < 20 ? () => people.value++ : null, + ), + ], + ), + 20.h, + // Per-person result card + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.theme.colorScheme.primaryContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: context.theme.colorScheme.primary.withValues( + alpha: 0.3, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.perPerson, + style: TextStyle( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + 4.h, + Text( + fromFmt.format(perPersonFrom), + style: context.theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: context.theme.colorScheme.primary, + ), + ), + Text( + '≈ ${toFmt.format(perPersonTo)}', + style: context.theme.textTheme.bodyLarge?.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + }), + ); + } +} + +// --------------------------------------------------------------------------- +// Tab 2 — Coin/Note Breakdown +// --------------------------------------------------------------------------- + +class _BreakdownTabView extends StatelessWidget { + final double amount; + final String currencyId; + + const _BreakdownTabView({required this.amount, required this.currencyId}); + + @override + Widget build(BuildContext context) { + var l10n = context.l10n; + var minor = _toMinor(amount, currencyId); + var breakdown = _breakdownFor(currencyId, minor); + + if (breakdown.isEmpty) { + return Center(child: Text(l10n.noResultsFound)); + } + + var fmt = NumberFormat.simpleCurrency( + name: currencyId.toUpperCase(), + locale: l10n.localeName, + ); + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + itemCount: breakdown.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + var item = breakdown[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: item.denom.isBill + ? context.theme.colorScheme.primaryContainer + : context.theme.colorScheme.secondaryContainer, + child: Text( + item.denom.isBill ? '💵' : '🪙', + style: const TextStyle(fontSize: 18), + ), + ), + title: Text( + item.denom.label, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '× ${item.count}', + style: context.theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + color: context.theme.colorScheme.primary, + ), + ), + 8.w, + Text( + fmt.format( + (item.denom.valueMinorUnits * item.count) / + (_minorUnitScale[currencyId.toLowerCase()] ?? 100), + ), + style: TextStyle( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Tab 3 — Wallet Check ("Can I Pay?") +// --------------------------------------------------------------------------- + +class _WalletTabView extends StatelessWidget { + final double requiredAmount; + final String currencyId; + final Map walletCounts; + + const _WalletTabView({ + required this.requiredAmount, + required this.currencyId, + required this.walletCounts, + }); + + @override + Widget build(BuildContext context) { + var l10n = context.l10n; + var denoms = _denominations[currencyId.toLowerCase()] ?? []; + var scale = _minorUnitScale[currencyId.toLowerCase()] ?? 100; + var requiredMinor = _toMinor(requiredAmount, currencyId); + + var fmt = NumberFormat.simpleCurrency( + name: currencyId.toUpperCase(), + locale: l10n.localeName, + ); + + return Column( + children: [ + // Summary bar — updates reactively + Obx(() { + var walletMinor = 0; + for (var i = 0; i < denoms.length; i++) { + walletMinor += + (walletCounts[i]?.value ?? 0) * denoms[i].valueMinorUnits; + } + + var canPay = walletMinor >= requiredMinor; + var diff = (walletMinor - requiredMinor).abs(); + var diffAmount = diff / scale; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + margin: const EdgeInsets.fromLTRB(20, 12, 20, 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: canPay + ? Colors.green.withValues(alpha: 0.15) + : context.theme.colorScheme.errorContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: canPay + ? Colors.green.withValues(alpha: 0.5) + : context.theme.colorScheme.error.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Text(canPay ? '✅' : '❌', style: const TextStyle(fontSize: 22)), + 12.w, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + canPay + ? l10n.youHaveEnough + : l10n.youAreShort(fmt.format(diffAmount)), + style: TextStyle( + fontWeight: FontWeight.bold, + color: canPay + ? Colors.green.shade700 + : context.theme.colorScheme.error, + ), + ), + Text( + '${l10n.walletTotal}: ${fmt.format(walletMinor / scale)} / ${l10n.required}: ${fmt.format(requiredAmount)}', + style: context.theme.textTheme.bodySmall?.copyWith( + color: context.theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + }), + // Denomination list + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + itemCount: denoms.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) { + var denom = denoms[i]; + return Obx(() { + var count = walletCounts[i]?.value ?? 0; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: denom.isBill + ? context.theme.colorScheme.primaryContainer + : context.theme.colorScheme.secondaryContainer, + child: Text( + denom.isBill ? '💵' : '🪙', + style: const TextStyle(fontSize: 18), + ), + ), + title: Text( + denom.label, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: count > 0 + ? Text( + '= ${fmt.format(denom.valueMinorUnits * count / scale)}', + style: TextStyle( + color: context.theme.colorScheme.primary, + fontSize: 12, + ), + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StepperButton( + icon: Icons.remove, + onPressed: count > 0 + ? () => walletCounts[i]!.value-- + : null, + small: true, + ), + SizedBox( + width: 36, + child: Center( + child: Text( + '$count', + style: TextStyle( + fontWeight: FontWeight.bold, + color: count > 0 + ? context.theme.colorScheme.primary + : context.theme.colorScheme.onSurfaceVariant, + fontSize: 16, + ), + ), + ), + ), + _StepperButton( + icon: Icons.add, + onPressed: () => walletCounts[i]!.value++, + small: true, + ), + ], + ), + ); + }); + }, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$label ', + style: TextStyle(color: context.theme.colorScheme.onSurfaceVariant), + ), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} + +class _StepperButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final bool small; + + const _StepperButton({ + required this.icon, + required this.onPressed, + this.small = false, + }); + + @override + Widget build(BuildContext context) { + var size = small ? 32.0 : 40.0; + return SizedBox( + width: size, + height: size, + child: IconButton.outlined( + padding: EdgeInsets.zero, + constraints: BoxConstraints.tight(Size(size, size)), + iconSize: small ? 16 : 20, + icon: Icon(icon), + onPressed: onPressed, + color: context.theme.colorScheme.primary, + disabledColor: context.theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.3, + ), + ), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 43b597d..e97c090 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -177,5 +177,21 @@ "quizScenarioTaxiRide": "Taxifahrt", "quizScenarioMuseumTicket": "Museumsticket", "quizScenarioGroceries": "Lebensmittel", - "quizScenarioHotelNight": "Hotelnacht" + "quizScenarioHotelNight": "Hotelnacht", + "customRateOverride": "Eigener Wechselkurs", + "customRateLabel": "Eigener Kurs für 1 {from} in {to}", + "resetToLiveRate": "Auf Live-Kurs zurücksetzen", + "customRateActive": "Eigener Kurs aktiv", + "customRateHelper": "Gib den Kurs ein, den du tatsächlich erhalten hast (z.B. Bar- oder Kartengebühr-Kurs)", + "invalidNumber": "Bitte gib eine gültige positive Zahl ein", + "splitBill": "Rechnung teilen", + "splitBillTab": "Teilen", + "numberOfPeople": "Anzahl Personen", + "perPerson": "Pro Person", + "denominationBreakdown": "Stückelung", + "walletCheck": "Kann ich zahlen?", + "youHaveEnough": "Du hast genug!", + "youAreShort": "Dir fehlen {amount}", + "walletTotal": "Geldbörse", + "required": "Benötigt" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 028e151..eebdbe5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "Taxi ride", "quizScenarioMuseumTicket": "Museum ticket", "quizScenarioGroceries": "Groceries", - "quizScenarioHotelNight": "Hotel night" + "quizScenarioHotelNight": "Hotel night", + "customRateOverride": "Custom Rate Override", + "customRateLabel": "Custom rate for 1 {from} in {to}", + "resetToLiveRate": "Reset to Live Rate", + "customRateActive": "Custom Rate Active", + "customRateHelper": "Enter the rate you actually got (e.g., card or cash rate)", + "invalidNumber": "Please enter a valid positive number", + "splitBill": "Split Bill", + "splitBillTab": "Split", + "numberOfPeople": "Number of People", + "perPerson": "Per person", + "denominationBreakdown": "Breakdown", + "walletCheck": "Can I Pay?", + "youHaveEnough": "You have enough!", + "youAreShort": "You're {amount} short", + "walletTotal": "Wallet", + "required": "Required" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 91a277e..43e8e55 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "Course en taxi", "quizScenarioMuseumTicket": "Billet de musée", "quizScenarioGroceries": "Courses", - "quizScenarioHotelNight": "Nuit d'hôtel" + "quizScenarioHotelNight": "Nuit d'hôtel", + "customRateOverride": "Taux personnalisé", + "customRateLabel": "Taux personnalisé pour 1 {from} en {to}", + "resetToLiveRate": "Réinitialiser au taux en direct", + "customRateActive": "Taux personnalisé actif", + "customRateHelper": "Entrez le taux que vous avez réellement obtenu (par exemple, le taux en espèces ou par carte)", + "invalidNumber": "Veuillez entrer un nombre positif valide", + "splitBill": "Partager l'addition", + "splitBillTab": "Partager", + "numberOfPeople": "Nombre de personnes", + "perPerson": "Par personne", + "denominationBreakdown": "Décomposition", + "walletCheck": "Puis-je payer ?", + "youHaveEnough": "Vous avez assez !", + "youAreShort": "Il vous manque {amount}", + "walletTotal": "Portefeuille", + "required": "Requis" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 4f4cf5f..1c2b35b 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "टैक्सी यात्रा", "quizScenarioMuseumTicket": "संग्रहालय टिकट", "quizScenarioGroceries": "किराने का सामान", - "quizScenarioHotelNight": "होटल की रात" + "quizScenarioHotelNight": "होटल की रात", + "customRateOverride": "कस्टम दर", + "customRateLabel": "1 {from} के लिए {to} में कस्टम दर", + "resetToLiveRate": "लाइव दर पर रीसेट करें", + "customRateActive": "कस्टम दर सक्रिय", + "customRateHelper": "वह दर दर्ज करें जो आपको वास्तव में मिली (जैसे नकद या कार्ड दर)", + "invalidNumber": "कृपया एक मान्य सकारात्मक संख्या दर्ज करें", + "splitBill": "बिल बाँटें", + "splitBillTab": "बाँटें", + "numberOfPeople": "लोगों की संख्या", + "perPerson": "प्रति व्यक्ति", + "denominationBreakdown": "मुद्रा विभाजन", + "walletCheck": "क्या मैं भुगतान कर सकता हूँ?", + "youHaveEnough": "आपके पास पर्याप्त राशि है!", + "youAreShort": "आपके पास {amount} कम है", + "walletTotal": "बटुआ", + "required": "आवश्यक" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index de18123..e467d0d 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "Naik taksi", "quizScenarioMuseumTicket": "Tiket museum", "quizScenarioGroceries": "Belanja bahan makanan", - "quizScenarioHotelNight": "Semalam di hotel" + "quizScenarioHotelNight": "Semalam di hotel", + "customRateOverride": "Kurs Kustom", + "customRateLabel": "Kurs kustom untuk 1 {from} ke {to}", + "resetToLiveRate": "Atur ulang ke kurs langsung", + "customRateActive": "Kurs kustom aktif", + "customRateHelper": "Masukkan kurs yang sebenarnya Anda dapatkan (misalnya kurs tunai atau kartu)", + "invalidNumber": "Harap masukkan angka positif yang valid", + "splitBill": "Bagi Tagihan", + "splitBillTab": "Bagi", + "numberOfPeople": "Jumlah Orang", + "perPerson": "Per orang", + "denominationBreakdown": "Pecahan", + "walletCheck": "Bisa Bayar?", + "youHaveEnough": "Uang Anda cukup!", + "youAreShort": "Kurang {amount}", + "walletTotal": "Dompet", + "required": "Dibutuhkan" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 11995d4..c0af406 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -177,5 +177,21 @@ "quizScenarioTaxiRide": "タクシー移動", "quizScenarioMuseumTicket": "美術館チケット", "quizScenarioGroceries": "食料品", - "quizScenarioHotelNight": "ホテル1泊" + "quizScenarioHotelNight": "ホテル1泊", + "customRateOverride": "カスタムレート設定", + "customRateLabel": "1 {from} あたりのカスタムレート({to})", + "resetToLiveRate": "現在のレートに戻す", + "customRateActive": "カスタムレート適用中", + "customRateHelper": "実際に適用されたレートを入力してください(現金両替やカード支払い時のレートなど)", + "invalidNumber": "有効な正の数値を入力してください", + "splitBill": "割り勘", + "splitBillTab": "割り勘", + "numberOfPeople": "人数", + "perPerson": "1人あたり", + "denominationBreakdown": "両替内訳", + "walletCheck": "支払えますか?", + "youHaveEnough": "支払えます!", + "youAreShort": "{amount}足りません", + "walletTotal": "お財布", + "required": "必要金額" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4be0630..8b26872 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1187,6 +1187,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Hotel night'** String get quizScenarioHotelNight; + + /// No description provided for @customRateOverride. + /// + /// In en, this message translates to: + /// **'Custom Rate Override'** + String get customRateOverride; + + /// No description provided for @customRateLabel. + /// + /// In en, this message translates to: + /// **'Custom rate for 1 {from} in {to}'** + String customRateLabel(Object from, Object to); + + /// No description provided for @resetToLiveRate. + /// + /// In en, this message translates to: + /// **'Reset to Live Rate'** + String get resetToLiveRate; + + /// No description provided for @customRateActive. + /// + /// In en, this message translates to: + /// **'Custom Rate Active'** + String get customRateActive; + + /// No description provided for @customRateHelper. + /// + /// In en, this message translates to: + /// **'Enter the rate you actually got (e.g., card or cash rate)'** + String get customRateHelper; + + /// No description provided for @invalidNumber. + /// + /// In en, this message translates to: + /// **'Please enter a valid positive number'** + String get invalidNumber; + + /// No description provided for @splitBill. + /// + /// In en, this message translates to: + /// **'Split Bill'** + String get splitBill; + + /// No description provided for @splitBillTab. + /// + /// In en, this message translates to: + /// **'Split'** + String get splitBillTab; + + /// No description provided for @numberOfPeople. + /// + /// In en, this message translates to: + /// **'Number of People'** + String get numberOfPeople; + + /// No description provided for @perPerson. + /// + /// In en, this message translates to: + /// **'Per person'** + String get perPerson; + + /// No description provided for @denominationBreakdown. + /// + /// In en, this message translates to: + /// **'Breakdown'** + String get denominationBreakdown; + + /// No description provided for @walletCheck. + /// + /// In en, this message translates to: + /// **'Can I Pay?'** + String get walletCheck; + + /// No description provided for @youHaveEnough. + /// + /// In en, this message translates to: + /// **'You have enough!'** + String get youHaveEnough; + + /// No description provided for @youAreShort. + /// + /// In en, this message translates to: + /// **'You\'re {amount} short'** + String youAreShort(Object amount); + + /// No description provided for @walletTotal. + /// + /// In en, this message translates to: + /// **'Wallet'** + String get walletTotal; + + /// No description provided for @required. + /// + /// In en, this message translates to: + /// **'Required'** + String get required; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 78ee0be..4b863d2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -618,4 +618,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get quizScenarioHotelNight => 'Hotelnacht'; + + @override + String get customRateOverride => 'Eigener Wechselkurs'; + + @override + String customRateLabel(Object from, Object to) { + return 'Eigener Kurs für 1 $from in $to'; + } + + @override + String get resetToLiveRate => 'Auf Live-Kurs zurücksetzen'; + + @override + String get customRateActive => 'Eigener Kurs aktiv'; + + @override + String get customRateHelper => + 'Gib den Kurs ein, den du tatsächlich erhalten hast (z.B. Bar- oder Kartengebühr-Kurs)'; + + @override + String get invalidNumber => 'Bitte gib eine gültige positive Zahl ein'; + + @override + String get splitBill => 'Rechnung teilen'; + + @override + String get splitBillTab => 'Teilen'; + + @override + String get numberOfPeople => 'Anzahl Personen'; + + @override + String get perPerson => 'Pro Person'; + + @override + String get denominationBreakdown => 'Stückelung'; + + @override + String get walletCheck => 'Kann ich zahlen?'; + + @override + String get youHaveEnough => 'Du hast genug!'; + + @override + String youAreShort(Object amount) { + return 'Dir fehlen $amount'; + } + + @override + String get walletTotal => 'Geldbörse'; + + @override + String get required => 'Benötigt'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d6bcfa8..11c0b1a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -618,4 +618,57 @@ class AppLocalizationsEn extends AppLocalizations { @override String get quizScenarioHotelNight => 'Hotel night'; + + @override + String get customRateOverride => 'Custom Rate Override'; + + @override + String customRateLabel(Object from, Object to) { + return 'Custom rate for 1 $from in $to'; + } + + @override + String get resetToLiveRate => 'Reset to Live Rate'; + + @override + String get customRateActive => 'Custom Rate Active'; + + @override + String get customRateHelper => + 'Enter the rate you actually got (e.g., card or cash rate)'; + + @override + String get invalidNumber => 'Please enter a valid positive number'; + + @override + String get splitBill => 'Split Bill'; + + @override + String get splitBillTab => 'Split'; + + @override + String get numberOfPeople => 'Number of People'; + + @override + String get perPerson => 'Per person'; + + @override + String get denominationBreakdown => 'Breakdown'; + + @override + String get walletCheck => 'Can I Pay?'; + + @override + String get youHaveEnough => 'You have enough!'; + + @override + String youAreShort(Object amount) { + return 'You\'re $amount short'; + } + + @override + String get walletTotal => 'Wallet'; + + @override + String get required => 'Required'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b4cdfa8..784ae54 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -619,4 +619,57 @@ class AppLocalizationsFr extends AppLocalizations { @override String get quizScenarioHotelNight => 'Nuit d\'hôtel'; + + @override + String get customRateOverride => 'Taux personnalisé'; + + @override + String customRateLabel(Object from, Object to) { + return 'Taux personnalisé pour 1 $from en $to'; + } + + @override + String get resetToLiveRate => 'Réinitialiser au taux en direct'; + + @override + String get customRateActive => 'Taux personnalisé actif'; + + @override + String get customRateHelper => + 'Entrez le taux que vous avez réellement obtenu (par exemple, le taux en espèces ou par carte)'; + + @override + String get invalidNumber => 'Veuillez entrer un nombre positif valide'; + + @override + String get splitBill => 'Partager l\'addition'; + + @override + String get splitBillTab => 'Partager'; + + @override + String get numberOfPeople => 'Nombre de personnes'; + + @override + String get perPerson => 'Par personne'; + + @override + String get denominationBreakdown => 'Décomposition'; + + @override + String get walletCheck => 'Puis-je payer ?'; + + @override + String get youHaveEnough => 'Vous avez assez !'; + + @override + String youAreShort(Object amount) { + return 'Il vous manque $amount'; + } + + @override + String get walletTotal => 'Portefeuille'; + + @override + String get required => 'Requis'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 65a2c3a..68f72a5 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -618,4 +618,57 @@ class AppLocalizationsHi extends AppLocalizations { @override String get quizScenarioHotelNight => 'होटल की रात'; + + @override + String get customRateOverride => 'कस्टम दर'; + + @override + String customRateLabel(Object from, Object to) { + return '1 $from के लिए $to में कस्टम दर'; + } + + @override + String get resetToLiveRate => 'लाइव दर पर रीसेट करें'; + + @override + String get customRateActive => 'कस्टम दर सक्रिय'; + + @override + String get customRateHelper => + 'वह दर दर्ज करें जो आपको वास्तव में मिली (जैसे नकद या कार्ड दर)'; + + @override + String get invalidNumber => 'कृपया एक मान्य सकारात्मक संख्या दर्ज करें'; + + @override + String get splitBill => 'बिल बाँटें'; + + @override + String get splitBillTab => 'बाँटें'; + + @override + String get numberOfPeople => 'लोगों की संख्या'; + + @override + String get perPerson => 'प्रति व्यक्ति'; + + @override + String get denominationBreakdown => 'मुद्रा विभाजन'; + + @override + String get walletCheck => 'क्या मैं भुगतान कर सकता हूँ?'; + + @override + String get youHaveEnough => 'आपके पास पर्याप्त राशि है!'; + + @override + String youAreShort(Object amount) { + return 'आपके पास $amount कम है'; + } + + @override + String get walletTotal => 'बटुआ'; + + @override + String get required => 'आवश्यक'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 45f92bb..6954965 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -618,4 +618,57 @@ class AppLocalizationsId extends AppLocalizations { @override String get quizScenarioHotelNight => 'Semalam di hotel'; + + @override + String get customRateOverride => 'Kurs Kustom'; + + @override + String customRateLabel(Object from, Object to) { + return 'Kurs kustom untuk 1 $from ke $to'; + } + + @override + String get resetToLiveRate => 'Atur ulang ke kurs langsung'; + + @override + String get customRateActive => 'Kurs kustom aktif'; + + @override + String get customRateHelper => + 'Masukkan kurs yang sebenarnya Anda dapatkan (misalnya kurs tunai atau kartu)'; + + @override + String get invalidNumber => 'Harap masukkan angka positif yang valid'; + + @override + String get splitBill => 'Bagi Tagihan'; + + @override + String get splitBillTab => 'Bagi'; + + @override + String get numberOfPeople => 'Jumlah Orang'; + + @override + String get perPerson => 'Per orang'; + + @override + String get denominationBreakdown => 'Pecahan'; + + @override + String get walletCheck => 'Bisa Bayar?'; + + @override + String get youHaveEnough => 'Uang Anda cukup!'; + + @override + String youAreShort(Object amount) { + return 'Kurang $amount'; + } + + @override + String get walletTotal => 'Dompet'; + + @override + String get required => 'Dibutuhkan'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8ff00ef..bf75622 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -613,4 +613,56 @@ class AppLocalizationsJa extends AppLocalizations { @override String get quizScenarioHotelNight => 'ホテル1泊'; + + @override + String get customRateOverride => 'カスタムレート設定'; + + @override + String customRateLabel(Object from, Object to) { + return '1 $from あたりのカスタムレート($to)'; + } + + @override + String get resetToLiveRate => '現在のレートに戻す'; + + @override + String get customRateActive => 'カスタムレート適用中'; + + @override + String get customRateHelper => '実際に適用されたレートを入力してください(現金両替やカード支払い時のレートなど)'; + + @override + String get invalidNumber => '有効な正の数値を入力してください'; + + @override + String get splitBill => '割り勘'; + + @override + String get splitBillTab => '割り勘'; + + @override + String get numberOfPeople => '人数'; + + @override + String get perPerson => '1人あたり'; + + @override + String get denominationBreakdown => '両替内訳'; + + @override + String get walletCheck => '支払えますか?'; + + @override + String get youHaveEnough => '支払えます!'; + + @override + String youAreShort(Object amount) { + return '$amount足りません'; + } + + @override + String get walletTotal => 'お財布'; + + @override + String get required => '必要金額'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 089ee05..17e74bb 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -617,4 +617,57 @@ class AppLocalizationsTr extends AppLocalizations { @override String get quizScenarioHotelNight => 'Otel gecesi'; + + @override + String get customRateOverride => 'Özel Döviz Kuru'; + + @override + String customRateLabel(Object from, Object to) { + return '1 $from için özel kur ($to)'; + } + + @override + String get resetToLiveRate => 'Canlı Kura Sıfırla'; + + @override + String get customRateActive => 'Özel Kur Aktif'; + + @override + String get customRateHelper => + 'Gerçekte aldığınız döviz kurunu girin (ör. nakit veya kart komisyonlu kur)'; + + @override + String get invalidNumber => 'Lütfen geçerli ve pozitif bir sayı girin'; + + @override + String get splitBill => 'Hesap Böl'; + + @override + String get splitBillTab => 'Böl'; + + @override + String get numberOfPeople => 'Kişi Sayısı'; + + @override + String get perPerson => 'Kişi başı'; + + @override + String get denominationBreakdown => 'Para Dökümü'; + + @override + String get walletCheck => 'Ödeyebilir miyim?'; + + @override + String get youHaveEnough => 'Yeterli paranız var!'; + + @override + String youAreShort(Object amount) { + return '$amount eksiğiniz var'; + } + + @override + String get walletTotal => 'Cüzdan'; + + @override + String get required => 'Gereken'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 5f10932..6bd48fe 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -611,4 +611,56 @@ class AppLocalizationsZh extends AppLocalizations { @override String get quizScenarioHotelNight => '酒店一晚'; + + @override + String get customRateOverride => '自定义汇率'; + + @override + String customRateLabel(Object from, Object to) { + return '1 $from 的自定义汇率 ($to)'; + } + + @override + String get resetToLiveRate => '重置为实时汇率'; + + @override + String get customRateActive => '自定义汇率已激活'; + + @override + String get customRateHelper => '输入您实际获得的汇率(例如现金或刷卡汇率)'; + + @override + String get invalidNumber => '请输入有效的正数'; + + @override + String get splitBill => '分摊账单'; + + @override + String get splitBillTab => '分摊'; + + @override + String get numberOfPeople => '人数'; + + @override + String get perPerson => '每人'; + + @override + String get denominationBreakdown => '面额分解'; + + @override + String get walletCheck => '我能付款吗?'; + + @override + String get youHaveEnough => '您的钱够了!'; + + @override + String youAreShort(Object amount) { + return '您还差 $amount'; + } + + @override + String get walletTotal => '钱包'; + + @override + String get required => '所需金额'; } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 4bd4d44..2322718 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "Taksi yolculuğu", "quizScenarioMuseumTicket": "Müze bileti", "quizScenarioGroceries": "Market alışverişi", - "quizScenarioHotelNight": "Otel gecesi" + "quizScenarioHotelNight": "Otel gecesi", + "customRateOverride": "Özel Döviz Kuru", + "customRateLabel": "1 {from} için özel kur ({to})", + "resetToLiveRate": "Canlı Kura Sıfırla", + "customRateActive": "Özel Kur Aktif", + "customRateHelper": "Gerçekte aldığınız döviz kurunu girin (ör. nakit veya kart komisyonlu kur)", + "invalidNumber": "Lütfen geçerli ve pozitif bir sayı girin", + "splitBill": "Hesap Böl", + "splitBillTab": "Böl", + "numberOfPeople": "Kişi Sayısı", + "perPerson": "Kişi başı", + "denominationBreakdown": "Para Dökümü", + "walletCheck": "Ödeyebilir miyim?", + "youHaveEnough": "Yeterli paranız var!", + "youAreShort": "{amount} eksiğiniz var", + "walletTotal": "Cüzdan", + "required": "Gereken" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 75087be..c976645 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -180,5 +180,21 @@ "quizScenarioTaxiRide": "出租车行程", "quizScenarioMuseumTicket": "博物馆门票", "quizScenarioGroceries": "日用品", - "quizScenarioHotelNight": "酒店一晚" + "quizScenarioHotelNight": "酒店一晚", + "customRateOverride": "自定义汇率", + "customRateLabel": "1 {from} 的自定义汇率 ({to})", + "resetToLiveRate": "重置为实时汇率", + "customRateActive": "自定义汇率已激活", + "customRateHelper": "输入您实际获得的汇率(例如现金或刷卡汇率)", + "invalidNumber": "请输入有效的正数", + "splitBill": "分摊账单", + "splitBillTab": "分摊", + "numberOfPeople": "人数", + "perPerson": "每人", + "denominationBreakdown": "面额分解", + "walletCheck": "我能付款吗?", + "youHaveEnough": "您的钱够了!", + "youAreShort": "您还差 {amount}", + "walletTotal": "钱包", + "required": "所需金额" } diff --git a/lib/providers/paren.dart b/lib/providers/paren.dart index afb79e5..9781213 100644 --- a/lib/providers/paren.dart +++ b/lib/providers/paren.dart @@ -37,6 +37,7 @@ class Paren extends GetxController { final favorites = [].obs; final sheets = [].obs; + final customRates = {}.obs; final currencyTextInput = '1'.obs; final currentPage = 1.obs; @@ -45,7 +46,7 @@ class Paren extends GetxController { Paren(); Future init() async { - await Future.wait([initCurrencies(), initFavorites(), initSheets()]); + await Future.wait([initCurrencies(), initFavorites(), initSheets(), initCustomRates()]); updateWidgetData(); } @@ -63,6 +64,7 @@ class Paren extends GetxController { calculatorInputHeight.value = 225.0; clearFavorites(); clearSheets(); + customRates.clear(); } Future fetchCurrencyDataOnline() async { @@ -393,11 +395,69 @@ class Paren extends GetxController { ); } + Future initCustomRates() async { + try { + var customRatesString = await sp.getString('customRates'); + if (customRatesString != null) { + Map decoded = jsonDecode(customRatesString); + customRates.value = decoded.map((key, value) => MapEntry(key, double.tryParse(value.toString()) ?? 0.0)); + } + } catch (e) { + logError('Error initializing custom rates', error: e); + } + } + + Future saveCustomRates() async { + await sp.setString('customRates', jsonEncode(customRates)); + } + + bool hasCustomRate(String fromId, String toId) { + return customRates.containsKey('${fromId.toLowerCase()}_${toId.toLowerCase()}') || + customRates.containsKey('${toId.toLowerCase()}_${fromId.toLowerCase()}'); + } + + double? getCustomRate(String fromId, String toId) { + var key = '${fromId.toLowerCase()}_${toId.toLowerCase()}'; + if (customRates.containsKey(key)) { + return customRates[key]; + } + var revKey = '${toId.toLowerCase()}_${fromId.toLowerCase()}'; + if (customRates.containsKey(revKey)) { + var val = customRates[revKey]; + if (val != null && val != 0.0) { + return 1.0 / val; + } + } + return null; + } + + Future setCustomRate(String fromId, String toId, double rate) async { + var key = '${fromId.toLowerCase()}_${toId.toLowerCase()}'; + var revKey = '${toId.toLowerCase()}_${fromId.toLowerCase()}'; + customRates.remove(revKey); + customRates[key] = rate; + await saveCustomRates(); + updateWidgetData(); + } + + Future removeCustomRate(String fromId, String toId) async { + var key = '${fromId.toLowerCase()}_${toId.toLowerCase()}'; + var revKey = '${toId.toLowerCase()}_${fromId.toLowerCase()}'; + customRates.remove(key); + customRates.remove(revKey); + await saveCustomRates(); + updateWidgetData(); + } + double convertValue( double amount, { required String fromId, required String toId, }) { + var customRate = getCustomRate(fromId, toId); + if (customRate != null) { + return amount * customRate; + } var from = currencyById(fromId); var to = currencyById(toId); return amount * to.rate / from.rate; diff --git a/lib/providers/sheet_exporter.dart b/lib/providers/sheet_exporter.dart new file mode 100644 index 0000000..5e57682 --- /dev/null +++ b/lib/providers/sheet_exporter.dart @@ -0,0 +1,150 @@ +import 'package:intl/intl.dart'; +import 'package:paren/classes/sheet.dart'; +import 'package:paren/classes/sheet_entry.dart'; +import 'package:paren/l10n/app_localizations.dart'; +import 'package:paren/providers/paren.dart'; + +class SheetExporter { + static String _csvEscape(String value) { + return '"${value.replaceAll('"', '""')}"'; + } + + static String _formatCsvAmount(double amount) => amount.toStringAsFixed(2); + + static String csvFileName(String sheetName) { + var sanitized = sheetName + .trim() + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(RegExp(r'\s+'), '_'); + return sanitized.isEmpty ? 'sheet_export.csv' : '$sanitized.csv'; + } + + static double calculateConvertedAmount( + double fromAmount, + String fromCurrency, + String toCurrency, + Paren paren, + ) { + return paren.convertValue(fromAmount, fromId: fromCurrency, toId: toCurrency); + } + + static Map calculateStats(List entries) { + if (entries.isEmpty) { + return {'sum': 0.0, 'avg': 0.0, 'min': 0.0, 'max': 0.0}; + } + + var fromAmounts = entries.map((e) => e.amount).toList(); + + var sum = fromAmounts.reduce((a, b) => a + b); + var avg = sum / fromAmounts.length; + var min = fromAmounts.reduce((a, b) => a < b ? a : b); + var max = fromAmounts.reduce((a, b) => a > b ? a : b); + + return {'sum': sum, 'avg': avg, 'min': min, 'max': max}; + } + + static Map calculateConvertedStats( + List entries, + Sheet sheet, + Paren paren, + ) { + if (entries.isEmpty) { + return {'sum': 0.0, 'avg': 0.0, 'min': 0.0, 'max': 0.0}; + } + + var convertedAmounts = entries + .map( + (e) => calculateConvertedAmount( + e.amount, + sheet.fromCurrency, + sheet.toCurrency, + paren, + ), + ) + .toList(); + + var sum = convertedAmounts.reduce((a, b) => a + b); + var avg = sum / convertedAmounts.length; + var min = convertedAmounts.reduce((a, b) => a < b ? a : b); + var max = convertedAmounts.reduce((a, b) => a > b ? a : b); + + return {'sum': sum, 'avg': avg, 'min': min, 'max': max}; + } + + static String getCategoryLabel(SheetEntryCategory category, AppLocalizations l10n) { + return switch (category) { + SheetEntryCategory.food => l10n.categoryFood, + SheetEntryCategory.transport => l10n.categoryTransport, + SheetEntryCategory.hotel => l10n.categoryHotel, + SheetEntryCategory.shopping => l10n.categoryShopping, + SheetEntryCategory.other => l10n.categoryOther, + }; + } + + static String buildCsvContent( + Sheet sheet, + List entries, + Paren paren, + AppLocalizations l10n, + ) { + // Standard ISO-8601 formatting for spreadsheet compatibility + var dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + var stats = calculateStats(entries); + var convertedStats = calculateConvertedStats(entries, sheet, paren); + + var lines = [ + 'sep=,', // Help Excel recognize the comma separator automatically + [ + l10n.date, + l10n.description, + l10n.category, + sheet.fromCurrency.toUpperCase(), + sheet.toCurrency.toUpperCase(), + ].map(_csvEscape).join(','), + ...entries.map((entry) { + var convertedAmount = calculateConvertedAmount( + entry.amount, + sheet.fromCurrency, + sheet.toCurrency, + paren, + ); + + return [ + dateFormat.format(entry.createdAt), + entry.name, + getCategoryLabel(entry.category, l10n), + _formatCsvAmount(entry.amount), + _formatCsvAmount(convertedAmount), + ].map(_csvEscape).join(','); + }), + '', + [ + l10n.statistics, + sheet.fromCurrency.toUpperCase(), + sheet.toCurrency.toUpperCase(), + ].map(_csvEscape).join(','), + [ + l10n.total, + _formatCsvAmount(stats['sum'] ?? 0.0), + _formatCsvAmount(convertedStats['sum'] ?? 0.0), + ].map(_csvEscape).join(','), + [ + l10n.average, + _formatCsvAmount(stats['avg'] ?? 0.0), + _formatCsvAmount(convertedStats['avg'] ?? 0.0), + ].map(_csvEscape).join(','), + [ + l10n.minimum, + _formatCsvAmount(stats['min'] ?? 0.0), + _formatCsvAmount(convertedStats['min'] ?? 0.0), + ].map(_csvEscape).join(','), + [ + l10n.maximum, + _formatCsvAmount(stats['max'] ?? 0.0), + _formatCsvAmount(convertedStats['max'] ?? 0.0), + ].map(_csvEscape).join(','), + ]; + + return lines.join('\n'); + } +} diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 1918636..dcd7323 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -222,7 +222,7 @@ class _HomeState extends State { return 0.h; } - return GlassBottomBar( + return GlassTabBar.bottom( verticalPadding: Platform.isIOS ? 20 : 38, unselectedIconColor: colorScheme.secondary, selectedIconColor: colorScheme.primary, @@ -237,15 +237,12 @@ class _HomeState extends State { glassColor: colorScheme.onSurface.withValues(alpha: 0.1), ), tabs: [ - GlassBottomBarTab(icon: Icon(Icons.list), label: l10n.sheets), - GlassBottomBarTab( - icon: Icon(Icons.calculate_outlined), - label: l10n.calculation, - ), - GlassBottomBarTab(icon: Icon(Icons.quiz_outlined), label: l10n.quiz), - GlassBottomBarTab(icon: Icon(Icons.settings), label: l10n.settings), + GlassTab(icon: Icon(Icons.list), label: l10n.sheets), + GlassTab(icon: Icon(Icons.calculate_outlined), label: l10n.calculation), + GlassTab(icon: Icon(Icons.quiz_outlined), label: l10n.quiz), + GlassTab(icon: Icon(Icons.settings), label: l10n.settings), ], - extraButton: GlassBottomBarExtraButton( + extraButton: GlassTabBarExtraButton( onTap: createSheetDialog, icon: Icon(Icons.add), label: l10n.createSheet, diff --git a/lib/screens/home/conversion.dart b/lib/screens/home/conversion.dart index 6cb7f6d..f4b7182 100644 --- a/lib/screens/home/conversion.dart +++ b/lib/screens/home/conversion.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:paren/classes/sheet_entry.dart'; import 'package:paren/components/adaptive_overlay.dart'; import 'package:paren/components/adaptive_snackbar.dart'; +import 'package:paren/components/bill_splitter_sheet.dart'; import 'package:paren/components/calculator_keyboard.dart'; import 'package:paren/components/currency_changer_row.dart'; import 'package:paren/l10n/app_localizations_extension.dart'; @@ -68,19 +69,18 @@ class _ConversionState extends State { (element) => element.id == paren.toCurrency.value, ); - var fromRate = fromCurrency.rate; - var toRate = toCurrency.rate; - var inputConverted = (double.tryParse(paren.currencyTextInput.value) ?? 0); - var convertedAmount = - (double.tryParse(paren.currencyTextInput.value) ?? 0) * - toRate / - fromRate; - var reConvertedAmount = - (double.tryParse(paren.currencyTextInput.value) ?? 0) * - fromRate / - toRate; + var convertedAmount = paren.convertValue( + inputConverted, + fromId: fromCurrency.id, + toId: toCurrency.id, + ); + var reConvertedAmount = paren.convertValue( + inputConverted, + fromId: toCurrency.id, + toId: fromCurrency.id, + ); var numberFormatFrom = NumberFormat.simpleCurrency( name: fromCurrency.id.toUpperCase(), @@ -100,6 +100,11 @@ class _ConversionState extends State { String inputStr = numberFormatFrom.format(inputConverted); String inputStrRe = numberFormatTo.format(inputConverted); + var hasOverride = paren.hasCustomRate( + fromCurrency.id, + toCurrency.id, + ); + return SelectionArea( child: Column( children: [ @@ -179,6 +184,45 @@ class _ConversionState extends State { ), ], ), + if (hasOverride) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: InputChip( + avatar: Icon( + Icons.edit, + size: 16, + color: context.theme.colorScheme.primary, + ), + label: Text( + context.l10n.customRateActive, + style: TextStyle( + color: context.theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + onDeleted: () { + paren.removeCustomRate( + fromCurrency.id, + toCurrency.id, + ); + }, + deleteIconColor: context.theme.colorScheme.primary, + backgroundColor: context + .theme + .colorScheme + .primaryContainer + .withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.theme.colorScheme.primary.withValues( + alpha: 0.3, + ), + ), + ), + ), + ), 12.h, buildConversionActions(inputConverted, inputStr, amountStr), ], @@ -295,6 +339,41 @@ class _ConversionState extends State { ], ), ), + IconButton( + icon: Icon( + Icons.call_split_outlined, + color: context.theme.colorScheme.primary, + ), + tooltip: l10n.splitBill, + onPressed: () { + Navigator.of(context).push( + adaptiveSheetRoute( + child: BillSplitterSheet( + initialAmount: + double.tryParse(paren.currencyTextInput.value) ?? 0, + fromCurrencyId: paren.fromCurrency.value, + toCurrencyId: paren.toCurrency.value, + ), + ), + ); + }, + ), + IconButton( + icon: Obx(() { + var active = paren.hasCustomRate( + paren.fromCurrency.value, + paren.toCurrency.value, + ); + return Icon( + Icons.price_change_outlined, + color: active + ? context.theme.colorScheme.primary + : context.theme.colorScheme.primary.withValues(alpha: 0.6), + ); + }), + onPressed: () => _showCustomRateDialog(context), + tooltip: l10n.customRateOverride, + ), IconButton( onPressed: () async { await Navigator.of( @@ -409,4 +488,95 @@ class _ConversionState extends State { ), ); } + + void _showCustomRateDialog(BuildContext context) { + var l10n = context.l10n; + var fromId = paren.fromCurrency.value.toUpperCase(); + var toId = paren.toCurrency.value.toUpperCase(); + + var currentOverride = paren.getCustomRate( + paren.fromCurrency.value, + paren.toCurrency.value, + ); + var liveFrom = paren.currencyById(paren.fromCurrency.value); + var liveTo = paren.currencyById(paren.toCurrency.value); + var currentRate = currentOverride ?? (liveTo.rate / liveFrom.rate); + + var controller = TextEditingController( + text: currentRate.toStringAsFixed(4), + ); + + Get.dialog( + AlertDialog( + constraints: adaptiveDialogConstraints(context), + insetPadding: adaptiveDialogInsetPadding(context), + title: Text(l10n.customRateOverride), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.customRateLabel(fromId, toId), + style: TextStyle(fontWeight: FontWeight.bold), + ), + 8.h, + TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + TextInputFormatter.withFunction((oldValue, newValue) { + var normalized = newValue.text.replaceAll(',', '.'); + return newValue.copyWith(text: normalized); + }), + ], + decoration: InputDecoration( + hintText: 'e.g. ${currentRate.toStringAsFixed(4)}', + helperText: l10n.customRateHelper, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + ], + ), + actions: [ + if (currentOverride != null) + TextButton( + onPressed: () { + paren.removeCustomRate( + paren.fromCurrency.value, + paren.toCurrency.value, + ); + Get.back(); + }, + child: Text( + l10n.resetToLiveRate, + style: TextStyle(color: context.theme.colorScheme.error), + ), + ), + TextButton(onPressed: () => Get.back(), child: Text(l10n.cancel)), + OutlinedButton( + onPressed: () { + var val = double.tryParse(controller.text); + if (val != null && val > 0) { + paren.setCustomRate( + paren.fromCurrency.value, + paren.toCurrency.value, + val, + ); + Get.back(); + } else { + AdaptiveSnackbar.showSnackBar( + context, + title: l10n.invalidNumber, + ); + } + }, + child: Text(l10n.apply), + ), + ], + ), + ); + } } diff --git a/lib/screens/home/details/sheet_detail.dart b/lib/screens/home/details/sheet_detail.dart index f2643bf..9519121 100644 --- a/lib/screens/home/details/sheet_detail.dart +++ b/lib/screens/home/details/sheet_detail.dart @@ -11,6 +11,7 @@ import 'package:paren/components/sheet_form_bottom_sheet.dart'; import 'package:paren/l10n/app_localizations_extension.dart'; import 'package:paren/providers/extensions.dart'; import 'package:paren/providers/paren.dart'; +import 'package:paren/providers/sheet_exporter.dart'; import 'package:paren/providers/sheets_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -47,12 +48,6 @@ class _SheetDetailState extends State { final reversedSorting = false.obs; final selectedCategoryFilter = Rxn(); - String _csvEscape(String value) { - return '"${value.replaceAll('"', '""')}"'; - } - - String _formatCsvAmount(double amount) => amount.toStringAsFixed(2); - String _categoryLabel(SheetEntryCategory category) { var l10n = context.l10n; return switch (category) { @@ -78,14 +73,6 @@ class _SheetDetailState extends State { return entries.where((entry) => entry.category == category).toList(); } - String _csvFileName(String sheetName) { - var sanitized = sheetName - .trim() - .replaceAll(RegExp(r'[^\w\s-]'), '') - .replaceAll(RegExp(r'\s+'), '_'); - return sanitized.isEmpty ? 'sheet_export.csv' : '$sanitized.csv'; - } - // Format currency amounts based on the currency code String formatCurrencyAmount(double amount, String currencyCode) { var numberFormat = NumberFormat.simpleCurrency( @@ -101,82 +88,22 @@ class _SheetDetailState extends State { String fromCurrency, String toCurrency, ) { - var fromC = paren.currencies.firstWhere( - (currency) => currency.id == fromCurrency, - orElse: () => paren.currencies.first, - ); - var toC = paren.currencies.firstWhere( - (currency) => currency.id == toCurrency, - orElse: () => paren.currencies.first, + return SheetExporter.calculateConvertedAmount( + fromAmount, + fromCurrency, + toCurrency, + paren, ); - - // Convert through EUR base rate - var inEur = fromAmount / fromC.rate; - return inEur * toC.rate; - } - - String _buildCsvContent(Sheet sheet, List entries) { - var l10n = context.l10n; - var dateFormat = DateFormat('dd. MMMM yyyy'); - var stats = _calculateStats(entries); - var convertedStats = _calculateConvertedStats(entries, sheet); - var lines = [ - [ - l10n.date, - l10n.description, - l10n.category, - sheet.fromCurrency.toUpperCase(), - sheet.toCurrency.toUpperCase(), - ].map(_csvEscape).join(','), - ...entries.map((entry) { - var convertedAmount = calculateConvertedAmount( - entry.amount, - sheet.fromCurrency, - sheet.toCurrency, - ); - - return [ - dateFormat.format(entry.createdAt), - entry.name, - _categoryLabel(entry.category), - _formatCsvAmount(entry.amount), - _formatCsvAmount(convertedAmount), - ].map(_csvEscape).join(','); - }), - '', - [ - l10n.statistics, - sheet.fromCurrency.toUpperCase(), - sheet.toCurrency.toUpperCase(), - ].map(_csvEscape).join(','), - [ - l10n.total, - _formatCsvAmount(stats['sum'] ?? 0.0), - _formatCsvAmount(convertedStats['sum'] ?? 0.0), - ].map(_csvEscape).join(','), - [ - l10n.average, - _formatCsvAmount(stats['avg'] ?? 0.0), - _formatCsvAmount(convertedStats['avg'] ?? 0.0), - ].map(_csvEscape).join(','), - [ - l10n.minimum, - _formatCsvAmount(stats['min'] ?? 0.0), - _formatCsvAmount(convertedStats['min'] ?? 0.0), - ].map(_csvEscape).join(','), - [ - l10n.maximum, - _formatCsvAmount(stats['max'] ?? 0.0), - _formatCsvAmount(convertedStats['max'] ?? 0.0), - ].map(_csvEscape).join(','), - ]; - - return lines.join('\n'); } Future _exportSheetAsCsv(Sheet sheet) async { var sortedEntries = sortBy(List.from(sheet.entries)); - var csvContent = _buildCsvContent(sheet, sortedEntries); + var csvContent = SheetExporter.buildCsvContent( + sheet, + sortedEntries, + paren, + context.l10n, + ); var box = context.findRenderObject() as RenderBox?; Rect? rect; if (box != null) { @@ -186,7 +113,7 @@ class _SheetDetailState extends State { await SharePlus.instance.share( ShareParams( files: [XFile.fromData(utf8.encode(csvContent), mimeType: 'text/csv')], - fileNameOverrides: [_csvFileName(sheet.name)], + fileNameOverrides: [SheetExporter.csvFileName(sheet.name)], sharePositionOrigin: rect, ), ); @@ -375,18 +302,7 @@ class _SheetDetailState extends State { // Calculate statistics for the entries Map _calculateStats(List entries) { - if (entries.isEmpty) { - return {'sum': 0.0, 'avg': 0.0, 'min': 0.0, 'max': 0.0}; - } - - var fromAmounts = entries.map((e) => e.amount).toList(); - - var sum = fromAmounts.reduce((a, b) => a + b); - var avg = sum / fromAmounts.length; - var min = fromAmounts.reduce((a, b) => a < b ? a : b); - var max = fromAmounts.reduce((a, b) => a > b ? a : b); - - return {'sum': sum, 'avg': avg, 'min': min, 'max': max}; + return SheetExporter.calculateStats(entries); } // Calculate statistics for the converted amounts @@ -394,26 +310,7 @@ class _SheetDetailState extends State { List entries, Sheet sheet, ) { - if (entries.isEmpty) { - return {'sum': 0.0, 'avg': 0.0, 'min': 0.0, 'max': 0.0}; - } - - var convertedAmounts = entries - .map( - (e) => calculateConvertedAmount( - e.amount, - sheet.fromCurrency, - sheet.toCurrency, - ), - ) - .toList(); - - var sum = convertedAmounts.reduce((a, b) => a + b); - var avg = sum / convertedAmounts.length; - var min = convertedAmounts.reduce((a, b) => a < b ? a : b); - var max = convertedAmounts.reduce((a, b) => a > b ? a : b); - - return {'sum': sum, 'avg': avg, 'min': min, 'max': max}; + return SheetExporter.calculateConvertedStats(entries, sheet, paren); } List sortBy(List entries) { diff --git a/pubspec.lock b/pubspec.lock index 3b85704..a1412e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,10 +205,10 @@ packages: dependency: "direct main" description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 url: "https://pub.dev" source: hosted - version: "5.9.2" + version: "5.10.0" dio_web_adapter: dependency: transitive description: @@ -498,10 +498,10 @@ packages: dependency: "direct main" description: name: liquid_glass_widgets - sha256: "0d90d073fe57a67ee9153f887ed4d3384f5fcedaf53ca52c840f5ba835792118" + sha256: "82cf91db40e5a7b8e8f18b170c2ceecfcdd3e1bd1fa04c0d16fcb3a596006f25" url: "https://pub.dev" source: hosted - version: "0.17.1" + version: "0.20.1" logging: dependency: transitive description: @@ -554,10 +554,10 @@ packages: dependency: "direct dev" description: name: msix - sha256: bed33b53662c7eac66dc4f9b4176d03140307e6a892ab21ce529be6f74393db0 + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" url: "https://pub.dev" source: hosted - version: "3.17.0" + version: "3.18.0" objective_c: dependency: transitive description: @@ -610,10 +610,10 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: @@ -714,10 +714,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + sha256: "9eee8283462d91a7a1c8bdb67d08874abd75a2f8fae3bc0ca033035e375fb3d8" url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.2.0" share_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fbdd855..341c7e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,10 +19,10 @@ dependencies: get: git: url: https://github.com/yurtemre7/flutter-get - dio: 5.9.2 + dio: 5.10.0 shared_preferences: 2.5.5 url_launcher: 6.3.2 - share_plus: 13.1.0 + share_plus: 13.2.0 fl_chart: 1.2.0 home_widget: git: @@ -31,7 +31,7 @@ dependencies: flutter_colorpicker: 1.1.0 stupid_simple_sheet: 0.9.1+1 json_annotation: 4.12.0 - liquid_glass_widgets: ^0.17.1 + liquid_glass_widgets: ^0.20.1 cupertino_calendar_picker: ^2.2.6 # image_picker: 1.1.2 @@ -46,7 +46,7 @@ dev_dependencies: # `dart run build_runner build` build_runner: 2.15.0 # `dart run msix:create` - msix: 3.17.0 + msix: 3.18.0 integration_test: sdk: flutter diff --git a/release-ios.sh b/release-ios.sh index 7ef9f77..697ef49 100755 --- a/release-ios.sh +++ b/release-ios.sh @@ -6,7 +6,7 @@ echo "==> flutter build ios --release --build-number='$git_count'" flutter build ios --release --build-number="$git_count" cd ios || exit echo "==> bundler exec fastlane release" -bundler exec fastlane release +bundler exec fastlane release || exit cd .. || exit git add ./ios/ git commit -m "chore: update ios version"