diff --git a/AGENTS.md b/AGENTS.md index 55c7681..b55cba1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,7 @@ - Install dependencies with `fvm flutter pub get` before finalizing code changes. - Run targeted checks first, then broader ones as needed. - Useful commands: -- `fvm flutter test integration_test` -- `dart format lib test integration_test` +- `dart format lib test` - `fvm flutter analyze` ## Repo Notes diff --git a/lib/components/adaptive_overlay.dart b/lib/components/adaptive_overlay.dart index 85f8dcf..b9ab7a2 100644 --- a/lib/components/adaptive_overlay.dart +++ b/lib/components/adaptive_overlay.dart @@ -67,9 +67,13 @@ class AdaptiveSheetLayout extends StatelessWidget { StupidSimpleSheetRoute adaptiveSheetRoute({ required Widget child, bool originateAboveBottomViewInset = false, + bool barrierDismissible = true, + bool draggable = true, }) { return StupidSimpleSheetRoute( originateAboveBottomViewInset: originateAboveBottomViewInset, + barrierDismissible: barrierDismissible, + draggable: draggable, child: AdaptiveSheetLayout(child: child), ); } diff --git a/lib/components/home_header.dart b/lib/components/home_header.dart index 65226bc..ecc42cc 100644 --- a/lib/components/home_header.dart +++ b/lib/components/home_header.dart @@ -41,7 +41,7 @@ class HomeHeader extends StatelessWidget implements PreferredSizeWidget { width: 52, child: isDesktop ? buildInfoIconButton(context, colorScheme) - : index != 2 + : index != 3 ? buildNavigateIconButtonForward(colorScheme) : buildInfoIconButton(context, colorScheme), ), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6f6b98b..43b597d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -146,5 +146,36 @@ "toLabel": "Nach", "more": "Mehr", "deleteEntryTitle": "Eintrag löschen", - "deleteEntryContent": "Möchten Sie \"{description}\" wirklich löschen?" + "deleteEntryContent": "Möchten Sie \"{description}\" wirklich löschen?", + "apply": "Anwenden", + "submit": "Absenden", + "quiz": "Quiz", + "quizSettings": "Quiz-Einstellungen", + "quizRoundsPerGame": "Runden pro Spiel: {count}", + "quizChoices": "Auswahl", + "quizInput": "Eingabe", + "quizEstimateInCurrency": "Schätzen in {currency}", + "quizYourEstimate": "Deine Schätzung", + "quizGoodCalibration": "Gut kalibriert", + "quizKeepCalibrating": "Weiter kalibrieren", + "quizCorrectAmount": "Richtig: {amount}", + "quizYourGuess": "Deine Schätzung: {amount}", + "quizYouWereOff": "Du lagst {percent}% daneben", + "quizShowSummary": "Zusammenfassung anzeigen", + "quizNextRound": "Nächste Runde", + "quizSummary": "Quiz-Zusammenfassung", + "quizScore": "Punktzahl", + "quizBestStreak": "Beste Serie", + "quizWithinTenPercent": "Innerhalb von 10%", + "quizAverageMiss": "Durchschnittliche Abweichung", + "quizSummaryCorrect": "Richtig {amount} · {percent}% daneben", + "quizPlayAgain": "Nochmal spielen", + "quizScenarioBottleOfWater": "Flasche Wasser", + "quizScenarioCoffee": "Kaffee", + "quizScenarioStreetSnack": "Snack am Straßenstand", + "quizScenarioLunch": "Mittagessen", + "quizScenarioTaxiRide": "Taxifahrt", + "quizScenarioMuseumTicket": "Museumsticket", + "quizScenarioGroceries": "Lebensmittel", + "quizScenarioHotelNight": "Hotelnacht" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ffbe12e..028e151 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -149,5 +149,36 @@ "toLabel": "To", "more": "More", "deleteEntryTitle": "Delete Entry", - "deleteEntryContent": "Are you sure you want to delete \"{description}\"?" + "deleteEntryContent": "Are you sure you want to delete \"{description}\"?", + "apply": "Apply", + "submit": "Submit", + "quiz": "Quiz", + "quizSettings": "Quiz settings", + "quizRoundsPerGame": "Rounds per game: {count}", + "quizChoices": "Choices", + "quizInput": "Input", + "quizEstimateInCurrency": "Estimate it in {currency}", + "quizYourEstimate": "Your estimate", + "quizGoodCalibration": "Good calibration", + "quizKeepCalibrating": "Keep calibrating", + "quizCorrectAmount": "Correct: {amount}", + "quizYourGuess": "Your guess: {amount}", + "quizYouWereOff": "You were {percent}% off", + "quizShowSummary": "Show summary", + "quizNextRound": "Next round", + "quizSummary": "Quiz summary", + "quizScore": "Score", + "quizBestStreak": "Best streak", + "quizWithinTenPercent": "Within 10%", + "quizAverageMiss": "Average miss", + "quizSummaryCorrect": "Correct {amount} · {percent}% off", + "quizPlayAgain": "Play again", + "quizScenarioBottleOfWater": "Bottle of water", + "quizScenarioCoffee": "Coffee", + "quizScenarioStreetSnack": "Street snack", + "quizScenarioLunch": "Lunch", + "quizScenarioTaxiRide": "Taxi ride", + "quizScenarioMuseumTicket": "Museum ticket", + "quizScenarioGroceries": "Groceries", + "quizScenarioHotelNight": "Hotel night" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c2c1d66..91a277e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -149,5 +149,36 @@ "toLabel": "Vers", "more": "Plus", "deleteEntryTitle": "Supprimer l'entrée", - "deleteEntryContent": "Voulez-vous vraiment supprimer \"{description}\" ?" + "deleteEntryContent": "Voulez-vous vraiment supprimer \"{description}\" ?", + "apply": "Appliquer", + "submit": "Envoyer", + "quiz": "Quiz", + "quizSettings": "Paramètres du quiz", + "quizRoundsPerGame": "Manches par partie : {count}", + "quizChoices": "Choix", + "quizInput": "Saisie", + "quizEstimateInCurrency": "Estimez en {currency}", + "quizYourEstimate": "Votre estimation", + "quizGoodCalibration": "Bonne calibration", + "quizKeepCalibrating": "Continuez à calibrer", + "quizCorrectAmount": "Correct : {amount}", + "quizYourGuess": "Votre estimation : {amount}", + "quizYouWereOff": "Vous aviez {percent} % d'écart", + "quizShowSummary": "Afficher le résumé", + "quizNextRound": "Manche suivante", + "quizSummary": "Résumé du quiz", + "quizScore": "Score", + "quizBestStreak": "Meilleure série", + "quizWithinTenPercent": "À moins de 10 %", + "quizAverageMiss": "Écart moyen", + "quizSummaryCorrect": "Correct {amount} · écart {percent} %", + "quizPlayAgain": "Rejouer", + "quizScenarioBottleOfWater": "Bouteille d'eau", + "quizScenarioCoffee": "Café", + "quizScenarioStreetSnack": "Snack de rue", + "quizScenarioLunch": "Déjeuner", + "quizScenarioTaxiRide": "Course en taxi", + "quizScenarioMuseumTicket": "Billet de musée", + "quizScenarioGroceries": "Courses", + "quizScenarioHotelNight": "Nuit d'hôtel" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 15665a1..4f4cf5f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -149,5 +149,36 @@ "toLabel": "में", "more": "अधिक", "deleteEntryTitle": "प्रविष्टि हटाएं", - "deleteEntryContent": "क्या आप वाकई \"{description}\" को हटाना चाहते हैं?" + "deleteEntryContent": "क्या आप वाकई \"{description}\" को हटाना चाहते हैं?", + "apply": "लागू करें", + "submit": "जमा करें", + "quiz": "क्विज़", + "quizSettings": "क्विज़ सेटिंग्स", + "quizRoundsPerGame": "प्रति गेम राउंड: {count}", + "quizChoices": "विकल्प", + "quizInput": "इनपुट", + "quizEstimateInCurrency": "{currency} में अनुमान लगाएं", + "quizYourEstimate": "आपका अनुमान", + "quizGoodCalibration": "अच्छा अनुमान", + "quizKeepCalibrating": "अभ्यास जारी रखें", + "quizCorrectAmount": "सही: {amount}", + "quizYourGuess": "आपका अनुमान: {amount}", + "quizYouWereOff": "आप {percent}% चूके", + "quizShowSummary": "सारांश दिखाएं", + "quizNextRound": "अगला राउंड", + "quizSummary": "क्विज़ सारांश", + "quizScore": "स्कोर", + "quizBestStreak": "सबसे अच्छी स्ट्रीक", + "quizWithinTenPercent": "10% के भीतर", + "quizAverageMiss": "औसत चूक", + "quizSummaryCorrect": "सही {amount} · {percent}% चूक", + "quizPlayAgain": "फिर खेलें", + "quizScenarioBottleOfWater": "पानी की बोतल", + "quizScenarioCoffee": "कॉफी", + "quizScenarioStreetSnack": "स्ट्रीट स्नैक", + "quizScenarioLunch": "दोपहर का भोजन", + "quizScenarioTaxiRide": "टैक्सी यात्रा", + "quizScenarioMuseumTicket": "संग्रहालय टिकट", + "quizScenarioGroceries": "किराने का सामान", + "quizScenarioHotelNight": "होटल की रात" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 42facd7..de18123 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -149,5 +149,36 @@ "toLabel": "Ke", "more": "Lainnya", "deleteEntryTitle": "Hapus Entri", - "deleteEntryContent": "Apakah Anda yakin ingin menghapus \"{description}\"?" + "deleteEntryContent": "Apakah Anda yakin ingin menghapus \"{description}\"?", + "apply": "Terapkan", + "submit": "Kirim", + "quiz": "Kuis", + "quizSettings": "Pengaturan kuis", + "quizRoundsPerGame": "Ronde per game: {count}", + "quizChoices": "Pilihan", + "quizInput": "Input", + "quizEstimateInCurrency": "Perkirakan dalam {currency}", + "quizYourEstimate": "Perkiraanmu", + "quizGoodCalibration": "Kalibrasi bagus", + "quizKeepCalibrating": "Terus kalibrasi", + "quizCorrectAmount": "Benar: {amount}", + "quizYourGuess": "Tebakanmu: {amount}", + "quizYouWereOff": "Kamu meleset {percent}%", + "quizShowSummary": "Tampilkan ringkasan", + "quizNextRound": "Ronde berikutnya", + "quizSummary": "Ringkasan kuis", + "quizScore": "Skor", + "quizBestStreak": "Rentetan terbaik", + "quizWithinTenPercent": "Dalam 10%", + "quizAverageMiss": "Rata-rata meleset", + "quizSummaryCorrect": "Benar {amount} · meleset {percent}%", + "quizPlayAgain": "Main lagi", + "quizScenarioBottleOfWater": "Sebotol air", + "quizScenarioCoffee": "Kopi", + "quizScenarioStreetSnack": "Camilan kaki lima", + "quizScenarioLunch": "Makan siang", + "quizScenarioTaxiRide": "Naik taksi", + "quizScenarioMuseumTicket": "Tiket museum", + "quizScenarioGroceries": "Belanja bahan makanan", + "quizScenarioHotelNight": "Semalam di hotel" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 27dff22..11995d4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -146,5 +146,36 @@ "toLabel": "変換先", "more": "その他", "deleteEntryTitle": "エントリを削除", - "deleteEntryContent": "「{description}」を本当に削除しますか?" + "deleteEntryContent": "「{description}」を本当に削除しますか?", + "apply": "適用", + "submit": "送信", + "quiz": "クイズ", + "quizSettings": "クイズ設定", + "quizRoundsPerGame": "1ゲームのラウンド数: {count}", + "quizChoices": "選択肢", + "quizInput": "入力", + "quizEstimateInCurrency": "{currency}で見積もる", + "quizYourEstimate": "あなたの見積もり", + "quizGoodCalibration": "感覚は良好", + "quizKeepCalibrating": "もう少し調整", + "quizCorrectAmount": "正解: {amount}", + "quizYourGuess": "あなたの予想: {amount}", + "quizYouWereOff": "{percent}%ずれていました", + "quizShowSummary": "結果を表示", + "quizNextRound": "次のラウンド", + "quizSummary": "クイズ結果", + "quizScore": "スコア", + "quizBestStreak": "最高連続正解", + "quizWithinTenPercent": "10%以内", + "quizAverageMiss": "平均誤差", + "quizSummaryCorrect": "正解 {amount} · {percent}%ずれ", + "quizPlayAgain": "もう一度", + "quizScenarioBottleOfWater": "水のボトル", + "quizScenarioCoffee": "コーヒー", + "quizScenarioStreetSnack": "屋台の軽食", + "quizScenarioLunch": "ランチ", + "quizScenarioTaxiRide": "タクシー移動", + "quizScenarioMuseumTicket": "美術館チケット", + "quizScenarioGroceries": "食料品", + "quizScenarioHotelNight": "ホテル1泊" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e43be7d..4be0630 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1001,6 +1001,192 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Are you sure you want to delete \"{description}\"?'** String deleteEntryContent(Object description); + + /// No description provided for @apply. + /// + /// In en, this message translates to: + /// **'Apply'** + String get apply; + + /// No description provided for @submit. + /// + /// In en, this message translates to: + /// **'Submit'** + String get submit; + + /// No description provided for @quiz. + /// + /// In en, this message translates to: + /// **'Quiz'** + String get quiz; + + /// No description provided for @quizSettings. + /// + /// In en, this message translates to: + /// **'Quiz settings'** + String get quizSettings; + + /// No description provided for @quizRoundsPerGame. + /// + /// In en, this message translates to: + /// **'Rounds per game: {count}'** + String quizRoundsPerGame(Object count); + + /// No description provided for @quizChoices. + /// + /// In en, this message translates to: + /// **'Choices'** + String get quizChoices; + + /// No description provided for @quizInput. + /// + /// In en, this message translates to: + /// **'Input'** + String get quizInput; + + /// No description provided for @quizEstimateInCurrency. + /// + /// In en, this message translates to: + /// **'Estimate it in {currency}'** + String quizEstimateInCurrency(Object currency); + + /// No description provided for @quizYourEstimate. + /// + /// In en, this message translates to: + /// **'Your estimate'** + String get quizYourEstimate; + + /// No description provided for @quizGoodCalibration. + /// + /// In en, this message translates to: + /// **'Good calibration'** + String get quizGoodCalibration; + + /// No description provided for @quizKeepCalibrating. + /// + /// In en, this message translates to: + /// **'Keep calibrating'** + String get quizKeepCalibrating; + + /// No description provided for @quizCorrectAmount. + /// + /// In en, this message translates to: + /// **'Correct: {amount}'** + String quizCorrectAmount(Object amount); + + /// No description provided for @quizYourGuess. + /// + /// In en, this message translates to: + /// **'Your guess: {amount}'** + String quizYourGuess(Object amount); + + /// No description provided for @quizYouWereOff. + /// + /// In en, this message translates to: + /// **'You were {percent}% off'** + String quizYouWereOff(Object percent); + + /// No description provided for @quizShowSummary. + /// + /// In en, this message translates to: + /// **'Show summary'** + String get quizShowSummary; + + /// No description provided for @quizNextRound. + /// + /// In en, this message translates to: + /// **'Next round'** + String get quizNextRound; + + /// No description provided for @quizSummary. + /// + /// In en, this message translates to: + /// **'Quiz summary'** + String get quizSummary; + + /// No description provided for @quizScore. + /// + /// In en, this message translates to: + /// **'Score'** + String get quizScore; + + /// No description provided for @quizBestStreak. + /// + /// In en, this message translates to: + /// **'Best streak'** + String get quizBestStreak; + + /// No description provided for @quizWithinTenPercent. + /// + /// In en, this message translates to: + /// **'Within 10%'** + String get quizWithinTenPercent; + + /// No description provided for @quizAverageMiss. + /// + /// In en, this message translates to: + /// **'Average miss'** + String get quizAverageMiss; + + /// No description provided for @quizSummaryCorrect. + /// + /// In en, this message translates to: + /// **'Correct {amount} · {percent}% off'** + String quizSummaryCorrect(Object amount, Object percent); + + /// No description provided for @quizPlayAgain. + /// + /// In en, this message translates to: + /// **'Play again'** + String get quizPlayAgain; + + /// No description provided for @quizScenarioBottleOfWater. + /// + /// In en, this message translates to: + /// **'Bottle of water'** + String get quizScenarioBottleOfWater; + + /// No description provided for @quizScenarioCoffee. + /// + /// In en, this message translates to: + /// **'Coffee'** + String get quizScenarioCoffee; + + /// No description provided for @quizScenarioStreetSnack. + /// + /// In en, this message translates to: + /// **'Street snack'** + String get quizScenarioStreetSnack; + + /// No description provided for @quizScenarioLunch. + /// + /// In en, this message translates to: + /// **'Lunch'** + String get quizScenarioLunch; + + /// No description provided for @quizScenarioTaxiRide. + /// + /// In en, this message translates to: + /// **'Taxi ride'** + String get quizScenarioTaxiRide; + + /// No description provided for @quizScenarioMuseumTicket. + /// + /// In en, this message translates to: + /// **'Museum ticket'** + String get quizScenarioMuseumTicket; + + /// No description provided for @quizScenarioGroceries. + /// + /// In en, this message translates to: + /// **'Groceries'** + String get quizScenarioGroceries; + + /// No description provided for @quizScenarioHotelNight. + /// + /// In en, this message translates to: + /// **'Hotel night'** + String get quizScenarioHotelNight; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7935eb4..78ee0be 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -513,4 +513,109 @@ class AppLocalizationsDe extends AppLocalizations { String deleteEntryContent(Object description) { return 'Möchten Sie \"$description\" wirklich löschen?'; } + + @override + String get apply => 'Anwenden'; + + @override + String get submit => 'Absenden'; + + @override + String get quiz => 'Quiz'; + + @override + String get quizSettings => 'Quiz-Einstellungen'; + + @override + String quizRoundsPerGame(Object count) { + return 'Runden pro Spiel: $count'; + } + + @override + String get quizChoices => 'Auswahl'; + + @override + String get quizInput => 'Eingabe'; + + @override + String quizEstimateInCurrency(Object currency) { + return 'Schätzen in $currency'; + } + + @override + String get quizYourEstimate => 'Deine Schätzung'; + + @override + String get quizGoodCalibration => 'Gut kalibriert'; + + @override + String get quizKeepCalibrating => 'Weiter kalibrieren'; + + @override + String quizCorrectAmount(Object amount) { + return 'Richtig: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'Deine Schätzung: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return 'Du lagst $percent% daneben'; + } + + @override + String get quizShowSummary => 'Zusammenfassung anzeigen'; + + @override + String get quizNextRound => 'Nächste Runde'; + + @override + String get quizSummary => 'Quiz-Zusammenfassung'; + + @override + String get quizScore => 'Punktzahl'; + + @override + String get quizBestStreak => 'Beste Serie'; + + @override + String get quizWithinTenPercent => 'Innerhalb von 10%'; + + @override + String get quizAverageMiss => 'Durchschnittliche Abweichung'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'Richtig $amount · $percent% daneben'; + } + + @override + String get quizPlayAgain => 'Nochmal spielen'; + + @override + String get quizScenarioBottleOfWater => 'Flasche Wasser'; + + @override + String get quizScenarioCoffee => 'Kaffee'; + + @override + String get quizScenarioStreetSnack => 'Snack am Straßenstand'; + + @override + String get quizScenarioLunch => 'Mittagessen'; + + @override + String get quizScenarioTaxiRide => 'Taxifahrt'; + + @override + String get quizScenarioMuseumTicket => 'Museumsticket'; + + @override + String get quizScenarioGroceries => 'Lebensmittel'; + + @override + String get quizScenarioHotelNight => 'Hotelnacht'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 281a093..d6bcfa8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,4 +513,109 @@ class AppLocalizationsEn extends AppLocalizations { String deleteEntryContent(Object description) { return 'Are you sure you want to delete \"$description\"?'; } + + @override + String get apply => 'Apply'; + + @override + String get submit => 'Submit'; + + @override + String get quiz => 'Quiz'; + + @override + String get quizSettings => 'Quiz settings'; + + @override + String quizRoundsPerGame(Object count) { + return 'Rounds per game: $count'; + } + + @override + String get quizChoices => 'Choices'; + + @override + String get quizInput => 'Input'; + + @override + String quizEstimateInCurrency(Object currency) { + return 'Estimate it in $currency'; + } + + @override + String get quizYourEstimate => 'Your estimate'; + + @override + String get quizGoodCalibration => 'Good calibration'; + + @override + String get quizKeepCalibrating => 'Keep calibrating'; + + @override + String quizCorrectAmount(Object amount) { + return 'Correct: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'Your guess: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return 'You were $percent% off'; + } + + @override + String get quizShowSummary => 'Show summary'; + + @override + String get quizNextRound => 'Next round'; + + @override + String get quizSummary => 'Quiz summary'; + + @override + String get quizScore => 'Score'; + + @override + String get quizBestStreak => 'Best streak'; + + @override + String get quizWithinTenPercent => 'Within 10%'; + + @override + String get quizAverageMiss => 'Average miss'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'Correct $amount · $percent% off'; + } + + @override + String get quizPlayAgain => 'Play again'; + + @override + String get quizScenarioBottleOfWater => 'Bottle of water'; + + @override + String get quizScenarioCoffee => 'Coffee'; + + @override + String get quizScenarioStreetSnack => 'Street snack'; + + @override + String get quizScenarioLunch => 'Lunch'; + + @override + String get quizScenarioTaxiRide => 'Taxi ride'; + + @override + String get quizScenarioMuseumTicket => 'Museum ticket'; + + @override + String get quizScenarioGroceries => 'Groceries'; + + @override + String get quizScenarioHotelNight => 'Hotel night'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9cc6d91..b4cdfa8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -514,4 +514,109 @@ class AppLocalizationsFr extends AppLocalizations { String deleteEntryContent(Object description) { return 'Voulez-vous vraiment supprimer \"$description\" ?'; } + + @override + String get apply => 'Appliquer'; + + @override + String get submit => 'Envoyer'; + + @override + String get quiz => 'Quiz'; + + @override + String get quizSettings => 'Paramètres du quiz'; + + @override + String quizRoundsPerGame(Object count) { + return 'Manches par partie : $count'; + } + + @override + String get quizChoices => 'Choix'; + + @override + String get quizInput => 'Saisie'; + + @override + String quizEstimateInCurrency(Object currency) { + return 'Estimez en $currency'; + } + + @override + String get quizYourEstimate => 'Votre estimation'; + + @override + String get quizGoodCalibration => 'Bonne calibration'; + + @override + String get quizKeepCalibrating => 'Continuez à calibrer'; + + @override + String quizCorrectAmount(Object amount) { + return 'Correct : $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'Votre estimation : $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return 'Vous aviez $percent % d\'écart'; + } + + @override + String get quizShowSummary => 'Afficher le résumé'; + + @override + String get quizNextRound => 'Manche suivante'; + + @override + String get quizSummary => 'Résumé du quiz'; + + @override + String get quizScore => 'Score'; + + @override + String get quizBestStreak => 'Meilleure série'; + + @override + String get quizWithinTenPercent => 'À moins de 10 %'; + + @override + String get quizAverageMiss => 'Écart moyen'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'Correct $amount · écart $percent %'; + } + + @override + String get quizPlayAgain => 'Rejouer'; + + @override + String get quizScenarioBottleOfWater => 'Bouteille d\'eau'; + + @override + String get quizScenarioCoffee => 'Café'; + + @override + String get quizScenarioStreetSnack => 'Snack de rue'; + + @override + String get quizScenarioLunch => 'Déjeuner'; + + @override + String get quizScenarioTaxiRide => 'Course en taxi'; + + @override + String get quizScenarioMuseumTicket => 'Billet de musée'; + + @override + String get quizScenarioGroceries => 'Courses'; + + @override + String get quizScenarioHotelNight => 'Nuit d\'hôtel'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index c1ba33b..65a2c3a 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -513,4 +513,109 @@ class AppLocalizationsHi extends AppLocalizations { String deleteEntryContent(Object description) { return 'क्या आप वाकई \"$description\" को हटाना चाहते हैं?'; } + + @override + String get apply => 'लागू करें'; + + @override + String get submit => 'जमा करें'; + + @override + String get quiz => 'क्विज़'; + + @override + String get quizSettings => 'क्विज़ सेटिंग्स'; + + @override + String quizRoundsPerGame(Object count) { + return 'प्रति गेम राउंड: $count'; + } + + @override + String get quizChoices => 'विकल्प'; + + @override + String get quizInput => 'इनपुट'; + + @override + String quizEstimateInCurrency(Object currency) { + return '$currency में अनुमान लगाएं'; + } + + @override + String get quizYourEstimate => 'आपका अनुमान'; + + @override + String get quizGoodCalibration => 'अच्छा अनुमान'; + + @override + String get quizKeepCalibrating => 'अभ्यास जारी रखें'; + + @override + String quizCorrectAmount(Object amount) { + return 'सही: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'आपका अनुमान: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return 'आप $percent% चूके'; + } + + @override + String get quizShowSummary => 'सारांश दिखाएं'; + + @override + String get quizNextRound => 'अगला राउंड'; + + @override + String get quizSummary => 'क्विज़ सारांश'; + + @override + String get quizScore => 'स्कोर'; + + @override + String get quizBestStreak => 'सबसे अच्छी स्ट्रीक'; + + @override + String get quizWithinTenPercent => '10% के भीतर'; + + @override + String get quizAverageMiss => 'औसत चूक'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'सही $amount · $percent% चूक'; + } + + @override + String get quizPlayAgain => 'फिर खेलें'; + + @override + String get quizScenarioBottleOfWater => 'पानी की बोतल'; + + @override + String get quizScenarioCoffee => 'कॉफी'; + + @override + String get quizScenarioStreetSnack => 'स्ट्रीट स्नैक'; + + @override + String get quizScenarioLunch => 'दोपहर का भोजन'; + + @override + String get quizScenarioTaxiRide => 'टैक्सी यात्रा'; + + @override + String get quizScenarioMuseumTicket => 'संग्रहालय टिकट'; + + @override + String get quizScenarioGroceries => 'किराने का सामान'; + + @override + String get quizScenarioHotelNight => 'होटल की रात'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 70c0ed5..45f92bb 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -513,4 +513,109 @@ class AppLocalizationsId extends AppLocalizations { String deleteEntryContent(Object description) { return 'Apakah Anda yakin ingin menghapus \"$description\"?'; } + + @override + String get apply => 'Terapkan'; + + @override + String get submit => 'Kirim'; + + @override + String get quiz => 'Kuis'; + + @override + String get quizSettings => 'Pengaturan kuis'; + + @override + String quizRoundsPerGame(Object count) { + return 'Ronde per game: $count'; + } + + @override + String get quizChoices => 'Pilihan'; + + @override + String get quizInput => 'Input'; + + @override + String quizEstimateInCurrency(Object currency) { + return 'Perkirakan dalam $currency'; + } + + @override + String get quizYourEstimate => 'Perkiraanmu'; + + @override + String get quizGoodCalibration => 'Kalibrasi bagus'; + + @override + String get quizKeepCalibrating => 'Terus kalibrasi'; + + @override + String quizCorrectAmount(Object amount) { + return 'Benar: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'Tebakanmu: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return 'Kamu meleset $percent%'; + } + + @override + String get quizShowSummary => 'Tampilkan ringkasan'; + + @override + String get quizNextRound => 'Ronde berikutnya'; + + @override + String get quizSummary => 'Ringkasan kuis'; + + @override + String get quizScore => 'Skor'; + + @override + String get quizBestStreak => 'Rentetan terbaik'; + + @override + String get quizWithinTenPercent => 'Dalam 10%'; + + @override + String get quizAverageMiss => 'Rata-rata meleset'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'Benar $amount · meleset $percent%'; + } + + @override + String get quizPlayAgain => 'Main lagi'; + + @override + String get quizScenarioBottleOfWater => 'Sebotol air'; + + @override + String get quizScenarioCoffee => 'Kopi'; + + @override + String get quizScenarioStreetSnack => 'Camilan kaki lima'; + + @override + String get quizScenarioLunch => 'Makan siang'; + + @override + String get quizScenarioTaxiRide => 'Naik taksi'; + + @override + String get quizScenarioMuseumTicket => 'Tiket museum'; + + @override + String get quizScenarioGroceries => 'Belanja bahan makanan'; + + @override + String get quizScenarioHotelNight => 'Semalam di hotel'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 99867c3..8ff00ef 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -508,4 +508,109 @@ class AppLocalizationsJa extends AppLocalizations { String deleteEntryContent(Object description) { return '「$description」を本当に削除しますか?'; } + + @override + String get apply => '適用'; + + @override + String get submit => '送信'; + + @override + String get quiz => 'クイズ'; + + @override + String get quizSettings => 'クイズ設定'; + + @override + String quizRoundsPerGame(Object count) { + return '1ゲームのラウンド数: $count'; + } + + @override + String get quizChoices => '選択肢'; + + @override + String get quizInput => '入力'; + + @override + String quizEstimateInCurrency(Object currency) { + return '$currencyで見積もる'; + } + + @override + String get quizYourEstimate => 'あなたの見積もり'; + + @override + String get quizGoodCalibration => '感覚は良好'; + + @override + String get quizKeepCalibrating => 'もう少し調整'; + + @override + String quizCorrectAmount(Object amount) { + return '正解: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'あなたの予想: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return '$percent%ずれていました'; + } + + @override + String get quizShowSummary => '結果を表示'; + + @override + String get quizNextRound => '次のラウンド'; + + @override + String get quizSummary => 'クイズ結果'; + + @override + String get quizScore => 'スコア'; + + @override + String get quizBestStreak => '最高連続正解'; + + @override + String get quizWithinTenPercent => '10%以内'; + + @override + String get quizAverageMiss => '平均誤差'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return '正解 $amount · $percent%ずれ'; + } + + @override + String get quizPlayAgain => 'もう一度'; + + @override + String get quizScenarioBottleOfWater => '水のボトル'; + + @override + String get quizScenarioCoffee => 'コーヒー'; + + @override + String get quizScenarioStreetSnack => '屋台の軽食'; + + @override + String get quizScenarioLunch => 'ランチ'; + + @override + String get quizScenarioTaxiRide => 'タクシー移動'; + + @override + String get quizScenarioMuseumTicket => '美術館チケット'; + + @override + String get quizScenarioGroceries => '食料品'; + + @override + String get quizScenarioHotelNight => 'ホテル1泊'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index e738699..089ee05 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -512,4 +512,109 @@ class AppLocalizationsTr extends AppLocalizations { String deleteEntryContent(Object description) { return '\"$description\" kaydını silmek istediğinizden emin misiniz?'; } + + @override + String get apply => 'Uygula'; + + @override + String get submit => 'Gönder'; + + @override + String get quiz => 'Test'; + + @override + String get quizSettings => 'Test ayarları'; + + @override + String quizRoundsPerGame(Object count) { + return 'Oyun başına tur: $count'; + } + + @override + String get quizChoices => 'Seçenekler'; + + @override + String get quizInput => 'Giriş'; + + @override + String quizEstimateInCurrency(Object currency) { + return '$currency olarak tahmin et'; + } + + @override + String get quizYourEstimate => 'Tahminin'; + + @override + String get quizGoodCalibration => 'İyi kalibrasyon'; + + @override + String get quizKeepCalibrating => 'Kalibre etmeye devam'; + + @override + String quizCorrectAmount(Object amount) { + return 'Doğru: $amount'; + } + + @override + String quizYourGuess(Object amount) { + return 'Senin tahminin: $amount'; + } + + @override + String quizYouWereOff(Object percent) { + return '%$percent saptın'; + } + + @override + String get quizShowSummary => 'Özeti göster'; + + @override + String get quizNextRound => 'Sonraki tur'; + + @override + String get quizSummary => 'Test özeti'; + + @override + String get quizScore => 'Puan'; + + @override + String get quizBestStreak => 'En iyi seri'; + + @override + String get quizWithinTenPercent => '%10 içinde'; + + @override + String get quizAverageMiss => 'Ortalama sapma'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return 'Doğru $amount · %$percent sapma'; + } + + @override + String get quizPlayAgain => 'Tekrar oyna'; + + @override + String get quizScenarioBottleOfWater => 'Bir şişe su'; + + @override + String get quizScenarioCoffee => 'Kahve'; + + @override + String get quizScenarioStreetSnack => 'Sokak atıştırmalığı'; + + @override + String get quizScenarioLunch => 'Öğle yemeği'; + + @override + String get quizScenarioTaxiRide => 'Taksi yolculuğu'; + + @override + String get quizScenarioMuseumTicket => 'Müze bileti'; + + @override + String get quizScenarioGroceries => 'Market alışverişi'; + + @override + String get quizScenarioHotelNight => 'Otel gecesi'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7b2844f..5f10932 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -506,4 +506,109 @@ class AppLocalizationsZh extends AppLocalizations { String deleteEntryContent(Object description) { return '确定要删除“$description”吗?'; } + + @override + String get apply => '应用'; + + @override + String get submit => '提交'; + + @override + String get quiz => '测验'; + + @override + String get quizSettings => '测验设置'; + + @override + String quizRoundsPerGame(Object count) { + return '每局轮数:$count'; + } + + @override + String get quizChoices => '选项'; + + @override + String get quizInput => '输入'; + + @override + String quizEstimateInCurrency(Object currency) { + return '估算为 $currency'; + } + + @override + String get quizYourEstimate => '你的估算'; + + @override + String get quizGoodCalibration => '校准不错'; + + @override + String get quizKeepCalibrating => '继续校准'; + + @override + String quizCorrectAmount(Object amount) { + return '正确:$amount'; + } + + @override + String quizYourGuess(Object amount) { + return '你的猜测:$amount'; + } + + @override + String quizYouWereOff(Object percent) { + return '你偏差了 $percent%'; + } + + @override + String get quizShowSummary => '显示总结'; + + @override + String get quizNextRound => '下一轮'; + + @override + String get quizSummary => '测验总结'; + + @override + String get quizScore => '得分'; + + @override + String get quizBestStreak => '最佳连击'; + + @override + String get quizWithinTenPercent => '10%以内'; + + @override + String get quizAverageMiss => '平均偏差'; + + @override + String quizSummaryCorrect(Object amount, Object percent) { + return '正确 $amount · 偏差 $percent%'; + } + + @override + String get quizPlayAgain => '再玩一次'; + + @override + String get quizScenarioBottleOfWater => '一瓶水'; + + @override + String get quizScenarioCoffee => '咖啡'; + + @override + String get quizScenarioStreetSnack => '街头小吃'; + + @override + String get quizScenarioLunch => '午餐'; + + @override + String get quizScenarioTaxiRide => '出租车行程'; + + @override + String get quizScenarioMuseumTicket => '博物馆门票'; + + @override + String get quizScenarioGroceries => '日用品'; + + @override + String get quizScenarioHotelNight => '酒店一晚'; } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index aa325f7..4bd4d44 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -149,5 +149,36 @@ "toLabel": "Şuna", "more": "Daha fazla", "deleteEntryTitle": "Kaydı Sil", - "deleteEntryContent": "\"{description}\" kaydını silmek istediğinizden emin misiniz?" + "deleteEntryContent": "\"{description}\" kaydını silmek istediğinizden emin misiniz?", + "apply": "Uygula", + "submit": "Gönder", + "quiz": "Test", + "quizSettings": "Test ayarları", + "quizRoundsPerGame": "Oyun başına tur: {count}", + "quizChoices": "Seçenekler", + "quizInput": "Giriş", + "quizEstimateInCurrency": "{currency} olarak tahmin et", + "quizYourEstimate": "Tahminin", + "quizGoodCalibration": "İyi kalibrasyon", + "quizKeepCalibrating": "Kalibre etmeye devam", + "quizCorrectAmount": "Doğru: {amount}", + "quizYourGuess": "Senin tahminin: {amount}", + "quizYouWereOff": "%{percent} saptın", + "quizShowSummary": "Özeti göster", + "quizNextRound": "Sonraki tur", + "quizSummary": "Test özeti", + "quizScore": "Puan", + "quizBestStreak": "En iyi seri", + "quizWithinTenPercent": "%10 içinde", + "quizAverageMiss": "Ortalama sapma", + "quizSummaryCorrect": "Doğru {amount} · %{percent} sapma", + "quizPlayAgain": "Tekrar oyna", + "quizScenarioBottleOfWater": "Bir şişe su", + "quizScenarioCoffee": "Kahve", + "quizScenarioStreetSnack": "Sokak atıştırmalığı", + "quizScenarioLunch": "Öğle yemeği", + "quizScenarioTaxiRide": "Taksi yolculuğu", + "quizScenarioMuseumTicket": "Müze bileti", + "quizScenarioGroceries": "Market alışverişi", + "quizScenarioHotelNight": "Otel gecesi" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f7e4a48..75087be 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -149,5 +149,36 @@ "toLabel": "到", "more": "更多", "deleteEntryTitle": "删除记录", - "deleteEntryContent": "确定要删除“{description}”吗?" + "deleteEntryContent": "确定要删除“{description}”吗?", + "apply": "应用", + "submit": "提交", + "quiz": "测验", + "quizSettings": "测验设置", + "quizRoundsPerGame": "每局轮数:{count}", + "quizChoices": "选项", + "quizInput": "输入", + "quizEstimateInCurrency": "估算为 {currency}", + "quizYourEstimate": "你的估算", + "quizGoodCalibration": "校准不错", + "quizKeepCalibrating": "继续校准", + "quizCorrectAmount": "正确:{amount}", + "quizYourGuess": "你的猜测:{amount}", + "quizYouWereOff": "你偏差了 {percent}%", + "quizShowSummary": "显示总结", + "quizNextRound": "下一轮", + "quizSummary": "测验总结", + "quizScore": "得分", + "quizBestStreak": "最佳连击", + "quizWithinTenPercent": "10%以内", + "quizAverageMiss": "平均偏差", + "quizSummaryCorrect": "正确 {amount} · 偏差 {percent}%", + "quizPlayAgain": "再玩一次", + "quizScenarioBottleOfWater": "一瓶水", + "quizScenarioCoffee": "咖啡", + "quizScenarioStreetSnack": "街头小吃", + "quizScenarioLunch": "午餐", + "quizScenarioTaxiRide": "出租车行程", + "quizScenarioMuseumTicket": "博物馆门票", + "quizScenarioGroceries": "日用品", + "quizScenarioHotelNight": "酒店一晚" } diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 03c9e04..1918636 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -17,6 +17,7 @@ import 'package:paren/providers/constants.dart'; import 'package:paren/providers/extensions.dart'; import 'package:paren/providers/paren.dart'; import 'package:paren/screens/home/conversion.dart'; +import 'package:paren/screens/home/currency_quiz.dart'; import 'package:paren/screens/home/customization.dart'; import 'package:paren/screens/home/sheets.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -167,6 +168,11 @@ class _HomeState extends State { color: context.theme.colorScheme.outlineVariant, ), Expanded(child: Conversion()), + Container( + width: 1, + color: context.theme.colorScheme.outlineVariant, + ), + Expanded(child: CurrencyQuizScreen()), // add border Container( width: 1, @@ -185,7 +191,12 @@ class _HomeState extends State { onPageChanged: (value) { paren.currentPage.value = value; }, - children: [Sheets(), Conversion(), Customization()], + children: [ + Sheets(), + Conversion(), + CurrencyQuizScreen(), + Customization(), + ], ); }), ), @@ -231,6 +242,7 @@ class _HomeState extends State { 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), ], extraButton: GlassBottomBarExtraButton( diff --git a/lib/screens/home/currency_quiz.dart b/lib/screens/home/currency_quiz.dart new file mode 100644 index 0000000..956f901 --- /dev/null +++ b/lib/screens/home/currency_quiz.dart @@ -0,0 +1,1080 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:paren/classes/currency.dart'; +import 'package:paren/components/adaptive_overlay.dart'; +import 'package:paren/l10n/app_localizations_extension.dart'; +import 'package:paren/providers/extensions.dart'; +import 'package:paren/providers/paren.dart'; + +enum QuizMode { multipleChoice, freeInput } + +enum _QuizPhase { answering, result, summary } + +enum QuizScenario { + bottleOfWater, + coffee, + streetSnack, + lunch, + taxiRide, + museumTicket, + groceries, + hotelNight, +} + +class QuizRound { + final Currency sourceCurrency; + final Currency homeCurrency; + final double sourceAmount; + final double correctAmount; + final QuizScenario scenario; + final List options; + + const QuizRound({ + required this.sourceCurrency, + required this.homeCurrency, + required this.sourceAmount, + required this.correctAmount, + required this.scenario, + required this.options, + }); +} + +class QuizAnswer { + final QuizRound round; + final double guess; + final double deviation; + final int points; + final bool streakHit; + + const QuizAnswer({ + required this.round, + required this.guess, + required this.deviation, + required this.points, + required this.streakHit, + }); +} + +class CurrencyQuizScreen extends StatefulWidget { + const CurrencyQuizScreen({super.key}); + + @override + State createState() => _CurrencyQuizScreenState(); +} + +class _CurrencyQuizScreenState extends State { + final Paren paren = Get.find(); + final random = Random(); + final inputController = TextEditingController(); + + final mode = QuizMode.multipleChoice.obs; + final phase = _QuizPhase.answering.obs; + final roundCount = 10.obs; + final roundIndex = 1.obs; + final score = 0.obs; + final streak = 0.obs; + final bestStreak = 0.obs; + final selectedAnswer = Rxn(); + final currentRound = Rxn(); + final lastAnswer = Rxn(); + final answers = [].obs; + + final scenarios = const [ + (scenario: QuizScenario.bottleOfWater, eurMin: 1.0, eurMax: 3.0), + (scenario: QuizScenario.coffee, eurMin: 2.5, eurMax: 6.0), + (scenario: QuizScenario.streetSnack, eurMin: 4.0, eurMax: 10.0), + (scenario: QuizScenario.lunch, eurMin: 10.0, eurMax: 22.0), + (scenario: QuizScenario.taxiRide, eurMin: 12.0, eurMax: 45.0), + (scenario: QuizScenario.museumTicket, eurMin: 8.0, eurMax: 24.0), + (scenario: QuizScenario.groceries, eurMin: 22.0, eurMax: 75.0), + (scenario: QuizScenario.hotelNight, eurMin: 70.0, eurMax: 190.0), + ]; + + Worker? workerFrom; + Worker? workerTo; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + startGame(); + }); + + workerFrom = ever(paren.fromCurrency, (_) { + startGame(); + }); + workerTo = ever(paren.toCurrency, (_) { + startGame(); + }); + } + + @override + void dispose() { + inputController.dispose(); + workerFrom?.dispose(); + workerTo?.dispose(); + super.dispose(); + } + + void startGame() { + answers.clear(); + roundIndex.value = 1; + score.value = 0; + streak.value = 0; + bestStreak.value = 0; + _nextRound(); + } + + void _nextRound() { + selectedAnswer.value = null; + lastAnswer.value = null; + inputController.clear(); + currentRound.value = _buildRound(); + phase.value = _QuizPhase.answering; + } + + QuizRound _buildRound() { + var homeCurrency = paren.currencyById(paren.toCurrency.value); + var sourceCurrency = paren.currencyById(paren.fromCurrency.value); + var scenario = scenarios[random.nextInt(scenarios.length)]; + var eurValue = + scenario.eurMin + + random.nextDouble() * (scenario.eurMax - scenario.eurMin); + var sourceAmount = paren.convertValue( + eurValue, + fromId: 'eur', + toId: sourceCurrency.id, + ); + sourceAmount = _roundReadableAmount(sourceAmount, sourceCurrency.id); + var correctAmount = paren.convertValue( + sourceAmount, + fromId: sourceCurrency.id, + toId: homeCurrency.id, + ); + var options = _buildOptions(correctAmount, homeCurrency.id); + + return QuizRound( + sourceCurrency: sourceCurrency, + homeCurrency: homeCurrency, + sourceAmount: sourceAmount, + correctAmount: correctAmount, + scenario: scenario.scenario, + options: options, + ); + } + + List _buildOptions(double correctAmount, String currencyId) { + var factors = [1.0, 0.9, 1.3, 0.3 + random.nextDouble() * 1.4]; + if (random.nextBool()) { + factors[1] = 1.1; + } + if (random.nextBool()) { + factors[2] = 0.7; + } + factors.shuffle(random); + + var options = factors + .map( + (factor) => _roundReadableAmount(correctAmount * factor, currencyId), + ) + .toList(); + var correctOption = _roundReadableAmount(correctAmount, currencyId); + var correctIndex = options.indexWhere((option) => option == correctOption); + if (correctIndex == -1) { + options[random.nextInt(options.length)] = correctOption; + } + + var cycleCount = 0; + while (options.toSet().length < options.length && cycleCount++ < 10) { + var duplicateIndex = options.indexWhere( + (option) => options.where((other) => other == option).length > 1, + ); + options[duplicateIndex] = _roundReadableAmount( + correctAmount * (0.55 + random.nextDouble() * 1.2), + currencyId, + ); + } + return options; + } + + double _roundReadableAmount(double value, String currencyId) { + if (value <= 0) { + return 0; + } + if (_zeroDecimalCurrencies.contains(currencyId.toLowerCase())) { + if (value >= 10000) { + return (value / 1000).round() * 1000; + } + if (value >= 1000) { + return (value / 100).round() * 100; + } + return value.roundToDouble(); + } + if (value >= 1000) { + return (value / 10).round() * 10; + } + if (value >= 100) { + return value.roundToDouble(); + } + if (value >= 10) { + return (value * 2).round() / 2; + } + return (value * 10).round() / 10; + } + + Future submitMultipleChoice(BuildContext context, double guess) async { + if (phase.value != _QuizPhase.answering) { + return; + } + selectedAnswer.value = guess; + var answer = _recordAnswer( + guess, + exactChoice: guess == _displayCorrectAmount, + ); + if (answer != null) { + await _showResultSheet(context, answer); + } + } + + Future submitFreeInput(BuildContext context) async { + if (phase.value != _QuizPhase.answering) { + return; + } + var normalized = inputController.text.replaceAll(',', '.').trim(); + var guess = double.tryParse(normalized); + if (guess == null || guess <= 0) { + HapticFeedback.lightImpact(); + return; + } + var answer = _recordAnswer(guess); + if (answer != null) { + await _showResultSheet(context, answer); + } + } + + QuizAnswer? _recordAnswer(double guess, {bool? exactChoice}) { + var round = currentRound.value; + if (round == null) { + return null; + } + var deviation = (guess - round.correctAmount).abs() / round.correctAmount; + var streakHit = exactChoice ?? deviation <= 0.1; + var roundPoints = exactChoice == false + ? 0 + : max(0, (100 * (1 - min(deviation, 1))).round()); + + if (streakHit) { + streak.value += 1; + bestStreak.value = max(bestStreak.value, streak.value); + roundPoints += min(streak.value * 5, 50); + HapticFeedback.mediumImpact(); + } else { + streak.value = 0; + HapticFeedback.heavyImpact(); + } + + score.value += roundPoints; + var answer = QuizAnswer( + round: round, + guess: guess, + deviation: deviation, + points: roundPoints, + streakHit: streakHit, + ); + lastAnswer.value = answer; + answers.add(answer); + phase.value = _QuizPhase.result; + return answer; + } + + Future _showResultSheet(BuildContext context, QuizAnswer answer) async { + var shouldContinue = await Navigator.of(context).push( + adaptiveSheetRoute( + barrierDismissible: false, + draggable: false, + child: ResultSheet( + answer: answer, + formatAmount: _formatAmount, + isFinalRound: roundIndex.value >= roundCount.value, + ), + ), + ); + if (!mounted || shouldContinue != true) { + return; + } + continueGame(); + } + + void continueGame() { + if (roundIndex.value >= roundCount.value) { + phase.value = _QuizPhase.summary; + return; + } + roundIndex.value += 1; + _nextRound(); + } + + double get _displayCorrectAmount { + var round = currentRound.value; + if (round == null) { + return 0; + } + return _roundReadableAmount(round.correctAmount, round.homeCurrency.id); + } + + NumberFormat _formatter(String currencyId) { + return NumberFormat.simpleCurrency( + name: currencyId.toUpperCase(), + locale: Get.locale?.toLanguageTag(), + ); + } + + String _formatAmount(double amount, String currencyId) { + return _formatter(currencyId).format(amount); + } + + Future _showSettings() async { + var draftCount = roundCount.value.obs; + await Get.dialog( + AlertDialog( + title: Text(context.l10n.quizSettings), + content: Obx( + () => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.quizRoundsPerGame(draftCount.value)), + Slider( + min: 5, + max: 20, + divisions: 3, + value: draftCount.value.toDouble(), + label: draftCount.value.toString(), + onChanged: (value) { + draftCount.value = value.round(); + }, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: Text(context.l10n.cancel)), + FilledButton( + onPressed: () { + roundCount.value = draftCount.value; + Get.back(); + startGame(); + }, + child: Text(context.l10n.apply), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + var round = currentRound.value; + + if (round == null) { + return const Center(child: CircularProgressIndicator()); + } + + return AnimatedSwitcher( + duration: 250.milliseconds, + child: switch (phase.value) { + _QuizPhase.summary => QuizSummaryScreen( + key: const ValueKey('summary'), + score: score.value, + bestStreak: bestStreak.value, + answers: answers, + formatAmount: _formatAmount, + onRestart: startGame, + ), + _ => _buildGame(context, round), + }, + ); + }); + } + + Widget _buildGame(BuildContext context, QuizRound round) { + return GestureDetector( + onHorizontalDragEnd: (details) { + if (phase.value == _QuizPhase.result && + (details.primaryVelocity ?? 0) < -200) { + continueGame(); + } + }, + child: ListView( + key: const ValueKey('game'), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _QuizTopBar( + mode: mode.value, + roundIndex: roundIndex.value, + roundCount: roundCount.value, + score: score.value, + streak: streak.value, + onModeChanged: (value) { + if (phase.value == _QuizPhase.answering) { + mode.value = value; + inputController.clear(); + } + }, + onSettings: _showSettings, + ), + 14.h, + QuizQuestionCard( + round: round, + sourceAmount: _formatAmount( + round.sourceAmount, + round.sourceCurrency.id, + ), + ), + 16.h, + if (mode.value == QuizMode.multipleChoice) + MultipleChoiceAnswerGrid( + options: round.options, + selectedAnswer: selectedAnswer.value, + correctAnswer: _displayCorrectAmount, + showResult: phase.value == _QuizPhase.result, + currencyId: round.homeCurrency.id, + formatAmount: _formatAmount, + onSelected: (guess) => submitMultipleChoice(context, guess), + ) + else + FreeInputAnswerField( + controller: inputController, + enabled: phase.value == _QuizPhase.answering, + currencyId: round.homeCurrency.id, + onSubmit: () => submitFreeInput(context), + ), + ], + ), + ); + } +} + +class _QuizTopBar extends StatelessWidget { + final QuizMode mode; + final int roundIndex; + final int roundCount; + final int score; + final int streak; + final ValueChanged onModeChanged; + final VoidCallback onSettings; + + const _QuizTopBar({ + required this.mode, + required this.roundIndex, + required this.roundCount, + required this.score, + required this.streak, + required this.onModeChanged, + required this.onSettings, + }); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.flag, + label: '$roundIndex/$roundCount', + ), + _MetricChip(icon: Icons.stars, label: '$score'), + _MetricChip( + icon: Icons.local_fire_department, + label: '$streak', + ), + ], + ), + ), + IconButton.filledTonal( + onPressed: onSettings, + icon: const Icon(Icons.tune), + tooltip: context.l10n.quizSettings, + ), + ], + ), + 12.h, + SegmentedButton( + segments: [ + ButtonSegment( + value: QuizMode.multipleChoice, + icon: const Icon(Icons.grid_view), + label: Text(context.l10n.quizChoices), + ), + ButtonSegment( + value: QuizMode.freeInput, + icon: const Icon(Icons.edit), + label: Text(context.l10n.quizInput), + ), + ], + selected: {mode}, + style: SegmentedButton.styleFrom( + selectedBackgroundColor: colorScheme.primaryContainer, + ), + onSelectionChanged: (values) { + onModeChanged(values.first); + }, + ), + ], + ); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + + const _MetricChip({required this.icon, required this.label}); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: colorScheme.secondary), + 6.w, + Text(label, style: const TextStyle(fontWeight: FontWeight.w700)), + ], + ), + ); + } +} + +class QuizQuestionCard extends StatelessWidget { + final QuizRound round; + final String sourceAmount; + + const QuizQuestionCard({ + super.key, + required this.round, + required this.sourceAmount, + }); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.32), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.primary.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quizScenarioLabel(context, round.scenario), + style: TextStyle( + color: colorScheme.secondary, + fontWeight: FontWeight.w700, + ), + ), + 10.h, + FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + '$sourceAmount (${round.sourceCurrency.id.toUpperCase()}) ➜ ?', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 0, + ), + ), + ), + 8.h, + Text( + context.l10n.quizEstimateInCurrency( + round.homeCurrency.id.toUpperCase(), + ), + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } +} + +class MultipleChoiceAnswerGrid extends StatelessWidget { + final List options; + final double? selectedAnswer; + final double correctAnswer; + final bool showResult; + final String currencyId; + final String Function(double amount, String currencyId) formatAmount; + final ValueChanged onSelected; + + const MultipleChoiceAnswerGrid({ + super.key, + required this.options, + required this.selectedAnswer, + required this.correctAnswer, + required this.showResult, + required this.currencyId, + required this.formatAmount, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + var isWide = constraints.maxWidth >= 560; + return GridView.builder( + itemCount: options.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isWide ? 2 : 1, + childAspectRatio: isWide ? 3.4 : 5.2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemBuilder: (context, index) { + var option = options[index]; + var isCorrect = option == correctAnswer; + var isSelected = selectedAnswer == option; + return _AnswerCard( + label: formatAmount(option, currencyId), + isCorrect: isCorrect, + isSelected: isSelected, + showResult: showResult, + onTap: () => onSelected(option), + ); + }, + ); + }, + ); + } +} + +class _AnswerCard extends StatelessWidget { + final String label; + final bool isCorrect; + final bool isSelected; + final bool showResult; + final VoidCallback onTap; + + const _AnswerCard({ + required this.label, + required this.isCorrect, + required this.isSelected, + required this.showResult, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + var color = colorScheme.surface; + var borderColor = colorScheme.outlineVariant; + var icon = Icons.circle_outlined; + + if (showResult && isCorrect) { + color = Colors.green.withValues(alpha: 0.18); + borderColor = Colors.green; + icon = Icons.check_circle; + } else if (showResult && isSelected) { + color = colorScheme.errorContainer; + borderColor = colorScheme.error; + icon = Icons.cancel; + } + + return AnimatedContainer( + duration: 220.milliseconds, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor, width: showResult ? 1.8 : 1), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: showResult ? null : onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(icon, color: borderColor), + 12.w, + Expanded( + child: FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 0, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class FreeInputAnswerField extends StatelessWidget { + final TextEditingController controller; + final bool enabled; + final String currencyId; + final VoidCallback onSubmit; + + const FreeInputAnswerField({ + super.key, + required this.controller, + required this.enabled, + required this.currencyId, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9\.,]')), + ], + textInputAction: TextInputAction.done, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.payments_outlined), + suffixText: currencyId.toUpperCase(), + labelText: context.l10n.quizYourEstimate, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onSubmitted: (_) => onSubmit(), + ), + ), + 10.w, + FilledButton.icon( + onPressed: enabled ? onSubmit : null, + icon: const Icon(Icons.check), + label: Text(context.l10n.submit), + ), + ], + ); + } +} + +class ResultSheet extends StatelessWidget { + final QuizAnswer answer; + final bool isFinalRound; + final String Function(double amount, String currencyId) formatAmount; + + const ResultSheet({ + super.key, + required this.answer, + required this.formatAmount, + required this.isFinalRound, + }); + + @override + Widget build(BuildContext context) { + return _ResultPanel( + answer: answer, + formatAmount: formatAmount, + onContinue: () => Navigator.of(context).pop(true), + isFinalRound: isFinalRound, + ); + } +} + +class _ResultPanel extends StatelessWidget { + final QuizAnswer answer; + final bool isFinalRound; + final String Function(double amount, String currencyId) formatAmount; + final VoidCallback onContinue; + + const _ResultPanel({ + required this.answer, + required this.formatAmount, + required this.onContinue, + required this.isFinalRound, + }); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + var closeness = (1 - answer.deviation).clamp(0.0, 1.0); + var homeId = answer.round.homeCurrency.id; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + answer.streakHit ? Icons.emoji_events : Icons.insights, + color: answer.streakHit + ? Colors.amber.shade700 + : colorScheme.primary, + ), + 10.w, + Expanded( + child: Text( + answer.streakHit + ? context.l10n.quizGoodCalibration + : context.l10n.quizKeepCalibrating, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + Text('+${answer.points}'), + ], + ), + 12.h, + Text( + context.l10n.quizCorrectAmount( + formatAmount(answer.round.correctAmount, homeId), + ), + ), + Text(context.l10n.quizYourGuess(formatAmount(answer.guess, homeId))), + 12.h, + LinearProgressIndicator( + value: closeness, + minHeight: 10, + borderRadius: BorderRadius.circular(8), + ), + 8.h, + Text( + context.l10n.quizYouWereOff((answer.deviation * 100).round()), + textAlign: TextAlign.center, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + 14.h, + FilledButton.icon( + onPressed: onContinue, + icon: Icon(isFinalRound ? Icons.leaderboard : Icons.arrow_forward), + label: Text( + isFinalRound + ? context.l10n.quizShowSummary + : context.l10n.quizNextRound, + ), + ), + ], + ), + ); + } +} + +class QuizSummaryScreen extends StatelessWidget { + final int score; + final int bestStreak; + final List answers; + final String Function(double amount, String currencyId) formatAmount; + final VoidCallback onRestart; + + const QuizSummaryScreen({ + super.key, + required this.score, + required this.bestStreak, + required this.answers, + required this.formatAmount, + required this.onRestart, + }); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + var averageDeviation = answers.isEmpty + ? 0 + : answers + .map((answer) => answer.deviation) + .reduce((left, right) => left + right) / + answers.length; + var accurateRounds = answers + .where((answer) => answer.deviation <= 0.1) + .length; + + return ListView( + key: const ValueKey('summary'), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.32), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.quizSummary, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: 0, + ), + ), + 14.h, + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _SummaryTile(label: context.l10n.quizScore, value: '$score'), + _SummaryTile( + label: context.l10n.quizBestStreak, + value: '$bestStreak', + ), + _SummaryTile( + label: context.l10n.quizWithinTenPercent, + value: '$accurateRounds/${answers.length}', + ), + _SummaryTile( + label: context.l10n.quizAverageMiss, + value: '${(averageDeviation * 100).round()}%', + ), + ], + ), + ], + ), + ), + 16.h, + ...answers.map( + (answer) => ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: answer.streakHit + ? Colors.green.withValues(alpha: 0.18) + : colorScheme.errorContainer, + child: Icon( + answer.streakHit ? Icons.check : Icons.close, + color: answer.streakHit ? Colors.green : colorScheme.error, + ), + ), + title: Text( + '${formatAmount(answer.round.sourceAmount, answer.round.sourceCurrency.id)} ${answer.round.sourceCurrency.id.toUpperCase()}', + ), + subtitle: Text( + context.l10n.quizSummaryCorrect( + formatAmount( + answer.round.correctAmount, + answer.round.homeCurrency.id, + ), + (answer.deviation * 100).round(), + ), + ), + trailing: Text('+${answer.points}'), + ), + ), + 12.h, + FilledButton.icon( + onPressed: onRestart, + icon: const Icon(Icons.refresh), + label: Text(context.l10n.quizPlayAgain), + ), + ], + ); + } +} + +String quizScenarioLabel(BuildContext context, QuizScenario scenario) { + return switch (scenario) { + QuizScenario.bottleOfWater => context.l10n.quizScenarioBottleOfWater, + QuizScenario.coffee => context.l10n.quizScenarioCoffee, + QuizScenario.streetSnack => context.l10n.quizScenarioStreetSnack, + QuizScenario.lunch => context.l10n.quizScenarioLunch, + QuizScenario.taxiRide => context.l10n.quizScenarioTaxiRide, + QuizScenario.museumTicket => context.l10n.quizScenarioMuseumTicket, + QuizScenario.groceries => context.l10n.quizScenarioGroceries, + QuizScenario.hotelNight => context.l10n.quizScenarioHotelNight, + }; +} + +class _SummaryTile extends StatelessWidget { + final String label; + final String value; + + const _SummaryTile({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + var colorScheme = context.theme.colorScheme; + return Container( + width: 138, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.74), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)), + 6.h, + Text( + value, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + ], + ), + ); + } +} + +const _zeroDecimalCurrencies = { + 'bif', + 'clp', + 'djf', + 'gnf', + 'isk', + 'jpy', + 'kmf', + 'krw', + 'pyg', + 'rwf', + 'ugx', + 'vnd', + 'vuv', + 'xaf', + 'xof', + 'xpf', +};