diff --git a/assets/fonts/manrope/Manrope-Bold.ttf b/assets/fonts/manrope/Manrope-Bold.ttf new file mode 100644 index 00000000..46420091 Binary files /dev/null and b/assets/fonts/manrope/Manrope-Bold.ttf differ diff --git a/assets/fonts/manrope/Manrope-Medium.ttf b/assets/fonts/manrope/Manrope-Medium.ttf new file mode 100644 index 00000000..7b88a7f5 Binary files /dev/null and b/assets/fonts/manrope/Manrope-Medium.ttf differ diff --git a/assets/fonts/manrope/Manrope-SemiBold.ttf b/assets/fonts/manrope/Manrope-SemiBold.ttf new file mode 100644 index 00000000..8a0f7d0b Binary files /dev/null and b/assets/fonts/manrope/Manrope-SemiBold.ttf differ diff --git a/assets/fonts/manrope/OFL.txt b/assets/fonts/manrope/OFL.txt new file mode 100644 index 00000000..472064af --- /dev/null +++ b/assets/fonts/manrope/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/outfit/OFL.txt b/assets/fonts/outfit/OFL.txt new file mode 100644 index 00000000..723cd447 --- /dev/null +++ b/assets/fonts/outfit/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/outfit/Outfit-Bold.ttf b/assets/fonts/outfit/Outfit-Bold.ttf new file mode 100644 index 00000000..9f355736 Binary files /dev/null and b/assets/fonts/outfit/Outfit-Bold.ttf differ diff --git a/assets/fonts/outfit/Outfit-Medium.ttf b/assets/fonts/outfit/Outfit-Medium.ttf new file mode 100644 index 00000000..90d88f84 Binary files /dev/null and b/assets/fonts/outfit/Outfit-Medium.ttf differ diff --git a/assets/fonts/outfit/Outfit-Regular.ttf b/assets/fonts/outfit/Outfit-Regular.ttf new file mode 100644 index 00000000..79276945 Binary files /dev/null and b/assets/fonts/outfit/Outfit-Regular.ttf differ diff --git a/assets/fonts/outfit/Outfit-SemiBold.ttf b/assets/fonts/outfit/Outfit-SemiBold.ttf new file mode 100644 index 00000000..38f00e9e Binary files /dev/null and b/assets/fonts/outfit/Outfit-SemiBold.ttf differ diff --git a/lib/core/app_bootstrap.dart b/lib/core/app_bootstrap.dart index 4ada8671..d082836b 100644 --- a/lib/core/app_bootstrap.dart +++ b/lib/core/app_bootstrap.dart @@ -9,6 +9,7 @@ import 'package:mostro/core/storage/db_location.dart'; import 'package:mostro/core/storage/app_data_dir.dart' if (dart.library.html) 'package:mostro/core/storage/app_data_dir_web.dart'; import 'package:mostro/core/app.dart'; +import 'package:mostro/core/font_licenses.dart'; import 'package:mostro/core/mostro_defaults.dart'; import 'package:mostro/core/services/identity_service.dart'; import 'package:mostro/core/test_environment.dart'; @@ -45,6 +46,7 @@ import 'package:mostro/features/notifications/providers/notifications_provider.d /// silently succeeding against a public one. Future bootstrapAndRun({List seedRelays = const []}) async { WidgetsFlutterBinding.ensureInitialized(); + registerFontLicenses(); // Initialize Firebase (no-op if firebase_options.dart is the placeholder). try { diff --git a/lib/core/app_theme.dart b/lib/core/app_theme.dart index 0d007014..1d4315cc 100644 --- a/lib/core/app_theme.dart +++ b/lib/core/app_theme.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +export 'package:mostro/core/order_book_palette.dart'; + /// Mostro design system tokens. /// /// All colors are defined here — zero hardcoded colors in widgets. @@ -145,182 +147,10 @@ class AppColors extends ThemeExtension { } } -// ── Order Book redesign palette ─────────────────────────────────────────────── - -/// Palette of the "Mostro UX Redesign" mock (Claude Design, screen -/// #3 · Order book), on the v1 tonal recipe: app bar, tabs and card share one -/// surface tone ([bg] == [bgCard]) while the list area behind the cards sits -/// on the slightly lighter [bgWell] — v1's inverted contrast: dark cards on a -/// lighter well, deepened by [cardShadow] and the [border] hairline, with -/// inner panels one more step up on [bgElevated]. The green glow of the -/// "Card Contrast Options" mock (option 07) stays, reserved for the one card -/// per screen that is selected / needs action. -/// Applied only to the redesigned Order Book screen while -/// the rest of the app migrates screen by screen; values follow the mock -/// except where it fails WCAG AA (4.5:1) on its real rendered surface — -/// [textTertiary] and [red] are lightened just enough to pass. The mock is -/// dark-only, so [light] is a legibility mapping onto the existing light -/// surfaces, darkened where needed to pass AA. Every text-role/surface pair is -/// locked by `test/core/order_book_palette_contrast_test.dart`. -@immutable -class OrderBookPalette { - const OrderBookPalette({ - required this.bg, - required this.bgWell, - required this.bgCard, - required this.bgElevated, - required this.border, - required this.textPrimary, - required this.textSecondary, - required this.textTertiary, - required this.tabInactive, - required this.green, - required this.greenDim, - required this.gold, - required this.goldDim, - required this.blue, - required this.blueFill, - required this.amber, - required this.red, - required this.glowBorder, - required this.glowRing, - required this.cardShadow, - }); - - final Color bg; - - /// Background of the list area behind the offer cards, one step lighter - /// than [bg] (v1's `dark1` well): the cards — which share [bg]'s tone — - /// read as darker panels floating on it. - final Color bgWell; - final Color bgCard; - final Color bgElevated; - final Color border; - final Color textPrimary; - final Color textSecondary; - final Color textTertiary; - - /// Unselected BUY/SELL tab label. In dark this keeps the mock's dimmed - /// value (a deliberately de-emphasized state); in light it must clear - /// WCAG AA since the tab is an interactive control. - final Color tabInactive; - final Color green; - final Color greenDim; - final Color gold; - final Color goldDim; - final Color blue; - final Color blueFill; - final Color amber; - final Color red; - - /// Border of the one selected / action-required card per screen - /// (mock: `rgba(143,224,74,0.55)`). Pair with [glowRing]; every other - /// card keeps the plain [border] hairline — if all cards glow, none does. - final Color glowBorder; - - /// Soft green halo behind the selected card - /// (mock: `0 0 0 1px rgba(143,224,74,0.25), 0 0 22px rgba(143,224,74,0.18)`). - final List glowRing; - - /// Depth shadow every offer card carries. With [bgCard] equal to [bg] - /// (the v1 recipe) this shadow — not fill contrast — is what makes the - /// card read as a card: a strong drop below plus a subtle sheen along the - /// top edge (v1's `AppTheme.cardShadow`). - final List cardShadow; - - static const dark = OrderBookPalette( - // v1's exact surface family (mobile `AppTheme`): chrome and card share - // `backgroundDark`, the list well is `dark1`, inner panels sit on - // `backgroundCard`. - bg: Color(0xFF171A23), - bgWell: Color(0xFF1D212C), - bgCard: Color(0xFF171A23), - bgElevated: Color(0xFF1E2230), - border: Color(0x0DFFFFFF), // rgba(255,255,255,0.05), v1's hairline - textPrimary: Color(0xFFF2F4F7), - textSecondary: Color(0xFFA8B0BC), - // Mock #6B7280 is 3.5:1 on the card — lightened to pass AA (4.6:1). - textTertiary: Color(0xFF8B93A1), - tabInactive: Color(0xFF4A5060), - green: Color(0xFF8FE04A), - greenDim: Color(0xFF2A4015), - gold: Color(0xFFFFC940), - goldDim: Color(0xFF3A2D0A), - blue: Color(0xFF7BB4F0), - blueFill: Color(0xFF1E2B42), - amber: Color(0xFFE89C3C), - // Mock #E5484D is 3.8:1 on its 13% pill fill — lightened to pass AA. - red: Color(0xFFF48489), - glowBorder: Color(0x8C8FE04A), // rgba(143,224,74,0.55) - glowRing: [ - BoxShadow( - color: Color(0x408FE04A), // rgba(143,224,74,0.25) - spreadRadius: 1, - ), - BoxShadow( - color: Color(0x2E8FE04A), // rgba(143,224,74,0.18) - blurRadius: 22, - ), - ], - cardShadow: [ - BoxShadow( - color: Color(0xB3000000), // black 70% - blurRadius: 15, - offset: Offset(0, 5), - spreadRadius: -3, - ), - BoxShadow( - color: Color(0x12FFFFFF), // white 7% — top-edge sheen - blurRadius: 1, - offset: Offset(0, -1), - ), - ], - ); - - static const light = OrderBookPalette( - // Same structural recipe as dark: white cards on a faintly darker list - // well, depth from [cardShadow]; inner panels one more step down. - bg: Color(0xFFFFFFFF), - bgWell: Color(0xFFF4F4F6), - bgCard: Color(0xFFFFFFFF), - bgElevated: Color(0xFFEEEEEE), - border: Color(0x14000000), - textPrimary: Color(0xFF1A1A1A), - textSecondary: Color(0xFF666666), - textTertiary: Color(0xFF696969), - tabInactive: Color(0xFF666666), - green: Color(0xFF426800), - greenDim: Color(0x26426800), - gold: Color(0xFF7E5C09), - goldDim: Color(0x267E5C09), - blue: Color(0xFF35638F), - blueFill: Color(0x2635638F), - amber: Color(0xFF845010), - red: Color(0xFFAE3333), - // Same alphas as dark over the light theme's AA-safe green. - glowBorder: Color(0x8C426800), - glowRing: [ - BoxShadow(color: Color(0x40426800), spreadRadius: 1), - BoxShadow(color: Color(0x2E426800), blurRadius: 22), - ], - cardShadow: [ - BoxShadow( - color: Color(0x1F000000), // black 12% — soft daylight drop - blurRadius: 15, - offset: Offset(0, 5), - spreadRadius: -3, - ), - ], - ); - - static OrderBookPalette of(BuildContext context) => - Theme.of(context).brightness == Brightness.dark ? dark : light; -} - // ── Drawer redesign palette ─────────────────────────────────────────────────── -/// Palette of the drawer redesign handoff (`design_handoff_drawer`, variants -/// 3b · dark and 3c · light): a floating gradient panel with decorative halos, +/// Palette of the drawer redesign (variants 3b · dark and 3c · light): a +/// floating gradient panel with decorative halos, /// tinted icon tiles and a release-stage chip. Values follow the handoff /// except where a text role fails WCAG AA (4.5:1) on the surface it really /// renders on — [tagline] sits at the centre of the green halo, and the light @@ -539,6 +369,23 @@ abstract final class AppRadius { static const double input = 8; } +// ── Typography tokens ───────────────────────────────────────────────────────── + +/// Font families bundled in `pubspec.yaml` (`fonts:`). +abstract final class AppFonts { + /// Interface text. Both themes set it as the default family, so a + /// `TextStyle` without a family inherits it. + static const String ui = 'Outfit'; + + /// Figures that must line up from card to card — amounts, premiums, sats, + /// ratings. Its digits are tabular. + /// + /// Only weights 500, 600 and 700 are bundled: a style that leaves + /// `fontWeight` unset (400) renders Medium. Set the weight explicitly, or + /// bundle `Manrope-Regular.ttf` before relying on 400. + static const String figures = 'Manrope'; +} + // ── Predefined colour instances ──────────────────────────────────────────────── const _dark = AppColors( @@ -616,6 +463,7 @@ ThemeData _buildTheme({ }) { final base = ThemeData( brightness: brightness, + fontFamily: AppFonts.ui, scaffoldBackgroundColor: scaffold, pageTransitionsTheme: const PageTransitionsTheme( builders: { diff --git a/lib/core/font_licenses.dart b/lib/core/font_licenses.dart new file mode 100644 index 00000000..4026036e --- /dev/null +++ b/lib/core/font_licenses.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Registers the SIL Open Font License of each bundled font family with the +/// [LicenseRegistry], next to the package licences Flutter already collects. +/// +/// The OFL lets the fonts ship inside the app only with their copyright +/// notice and licence, and `pubspec.yaml` bundles those texts as assets for +/// exactly this. +void registerFontLicenses() { + LicenseRegistry.addLicense(() async* { + for (final (family, asset) in const [ + ('Outfit', 'assets/fonts/outfit/OFL.txt'), + ('Manrope', 'assets/fonts/manrope/OFL.txt'), + ]) { + yield LicenseEntryWithLineBreaks([ + family, + ], await rootBundle.loadString(asset)); + } + }); +} diff --git a/lib/core/order_book_palette.dart b/lib/core/order_book_palette.dart new file mode 100644 index 00000000..f5fb2efa --- /dev/null +++ b/lib/core/order_book_palette.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; + +/// Palette of the order-book redesign (variants 4b · order card and +/// 4d · create-order button), shared by the bottom bar. +/// +/// Dark values are the handoff's tokens. The handoff is dark-only, so [light] +/// is a legibility mapping onto the drawer's light (3c) surfaces. Where a text +/// role fails WCAG AA (4.5:1) on the surface it really renders on it is +/// adjusted just enough to pass — in dark only [textFaint]. Every text-role / +/// surface pair is locked by `test/core/order_book_palette_contrast_test.dart`. +@immutable +class OrderBookPalette { + const OrderBookPalette({ + required this.bg, + required this.surface, + required this.surfaceNav, + required this.inset, + required this.border, + required this.borderHighlight, + required this.pressed, + required this.lime, + required this.onLime, + required this.limeText, + required this.limeInk, + required this.limeIcon, + required this.sell, + required this.onSell, + required this.yellow, + required this.yellowInk, + required this.premiumMid, + required this.premiumHigh, + required this.textPrimary, + required this.textStrong, + required this.textBody, + required this.textMuted, + required this.textSecondary, + required this.textTertiary, + required this.textFaint, + required this.textNew, + required this.sortLabel, + required this.starEmpty, + required this.divider, + required this.notif, + required this.tabTrack, + required this.tabActiveFill, + required this.tabActiveBorder, + required this.chipFill, + required this.chipBorder, + required this.currencyChipFill, + required this.bestChipFill, + required this.bestChipBorder, + required this.reputableChipFill, + required this.reputableChipBorder, + required this.navBorder, + required this.scrim, + required this.scrimText, + required this.fabClose, + required this.fabCloseBorder, + required this.fabCloseIcon, + required this.fabShadow, + required this.buyShadow, + required this.sellShadow, + }); + + /// Page background. + final Color bg; + + /// Order card. + final Color surface; + + /// Bottom navigation bar. + final Color surfaceNav; + + /// Reputation strip inside a card (translucent over [surface]). + final Color inset; + + /// Card hairline. + final Color border; + + /// Border of the best-premium card — the one highlighted card per list. + final Color borderHighlight; + + /// Ink highlight of a pressed card. + final Color pressed; + + /// Brand fill: the create-order button and the Buy button. + final Color lime; + final Color onLime; + + /// Lime used as text or icon on a surface: active destination, premium in + /// the taker's favour, the clear-filters action. In light it is darkened, + /// since [lime] itself is unreadable on white. + final Color limeText; + + /// Text on lime tints: the active tab, the best-premium chip, the sats + /// figure. + final Color limeInk; + + /// The filter chip's funnel. + final Color limeIcon; + + /// The Sell button. + final Color sell; + final Color onSell; + + /// Rating star. + final Color yellow; + + /// Most-reputable chip text. + final Color yellowInk; + + /// Premium up to 3 points against the taker. + final Color premiumMid; + + /// Premium more than 3 points against the taker. + final Color premiumHigh; + + final Color textPrimary; + final Color textStrong; + final Color textBody; + final Color textMuted; + final Color textSecondary; + final Color textTertiary; + + /// Relative time and the "premium" caption. + final Color textFaint; + + /// "New" in place of the rating of a maker without trades. + final Color textNew; + + /// Current sort criterion on the filter row. + final Color sortLabel; + + /// Rating star of a maker nobody has rated. + final Color starEmpty; + + /// `|` separators of the reputation strip. + final Color divider; + + /// Notification dot. + final Color notif; + + final Color tabTrack; + final Color tabActiveFill; + final Color tabActiveBorder; + + /// "Filter" chip. + final Color chipFill; + final Color chipBorder; + + final Color currencyChipFill; + final Color bestChipFill; + final Color bestChipBorder; + final Color reputableChipFill; + final Color reputableChipBorder; + + /// Top edge of the bottom bar. + final Color navBorder; + + /// Full-screen scrim of the open create-order button. + final Color scrim; + + /// "Tap outside to close" on the scrim. + final Color scrimText; + + /// The create-order button in its open (✕) state. + final Color fabClose; + final Color fabCloseBorder; + final Color fabCloseIcon; + + final List fabShadow; + final List buyShadow; + final List sellShadow; + + static const dark = OrderBookPalette( + bg: Color(0xFF12161F), + surface: Color(0xFF1A2030), + surfaceNav: Color(0xFF151A24), + inset: Color(0x0AFFFFFF), // white 4% + border: Color(0x0FFFFFFF), // white 6% + borderHighlight: Color(0x3892D64F), // rgba(146,214,79,0.22) + pressed: Color(0x0DFFFFFF), // white 5% + lime: Color(0xFF92D64F), + onLime: Color(0xFF12161F), + limeText: Color(0xFF92D64F), + limeInk: Color(0xFFC6F09A), + limeIcon: Color(0xFFB7E38A), + sell: Color(0xFFFF8B8B), + onSell: Color(0xFF2A1015), + yellow: Color(0xFFF2D14B), + yellowInk: Color(0xFFF7DE72), + premiumMid: Color(0xFFC9A24D), + premiumHigh: Color(0xFFD9A84E), + textPrimary: Color(0xFFEEF1F6), + textStrong: Color(0xFFE4E9F2), + textBody: Color(0xFFD6DCE8), + textMuted: Color(0xFFA6B0C2), + textSecondary: Color(0xFF8B97AD), + textTertiary: Color(0xFF7E899E), + // Handoff #6B7589 is 3.5:1 on the card — lightened to pass AA. + textFaint: Color(0xFF808A9E), + textNew: Color(0xFFB8C0CF), + sortLabel: Color(0xFF9AA4B8), + starEmpty: Color(0xFF5D6879), + divider: Color(0xFF4A5364), + notif: Color(0xFFFF6B6B), + tabTrack: Color(0x0DFFFFFF), // white 5% + tabActiveFill: Color(0x2992D64F), // rgba(146,214,79,0.16) + tabActiveBorder: Color(0x4D92D64F), // rgba(146,214,79,0.30) + chipFill: Color(0x0DFFFFFF), // white 5% + chipBorder: Color(0x12FFFFFF), // white 7% + currencyChipFill: Color(0x0FFFFFFF), // white 6% + bestChipFill: Color(0x2492D64F), // rgba(146,214,79,0.14) + bestChipBorder: Color(0x4D92D64F), // rgba(146,214,79,0.30) + reputableChipFill: Color(0x24F2D14B), // rgba(242,209,75,0.14) + reputableChipBorder: Color(0x4DF2D14B), // rgba(242,209,75,0.30) + navBorder: Color(0x12FFFFFF), // white 7% + scrim: Color(0xD1080B10), // rgba(8,11,16,0.82) + scrimText: Color(0xFF7E899E), + fabClose: Color(0xFF2A3244), + fabCloseBorder: Color(0x1AFFFFFF), // white 10% + fabCloseIcon: Color(0xFFE4E9F2), + fabShadow: [ + BoxShadow( + color: Color(0x8C92D64F), // rgba(146,214,79,0.55) + offset: Offset(0, 10), + blurRadius: 24, + spreadRadius: -6, + ), + ], + buyShadow: [ + BoxShadow( + color: Color(0x9992D64F), // rgba(146,214,79,0.60) + offset: Offset(0, 10), + blurRadius: 26, + spreadRadius: -8, + ), + ], + sellShadow: [ + BoxShadow( + color: Color(0x73FF8B8B), // rgba(255,139,139,0.45) + offset: Offset(0, 10), + blurRadius: 26, + spreadRadius: -8, + ), + ], + ); + + static const light = OrderBookPalette( + bg: Color(0xFFF4F6F4), + surface: Color(0xFFFFFFFF), + surfaceNav: Color(0xFFFFFFFF), + inset: Color(0x0A12161F), // ink 4% + border: Color(0x1412161F), // ink 8% + borderHighlight: Color(0x735C9130), // rgba(92,145,48,0.45) + pressed: Color(0x0A12161F), + lime: Color(0xFF92D64F), + onLime: Color(0xFF12161F), + limeText: Color(0xFF3E6B1C), + limeInk: Color(0xFF3E6B1C), + limeIcon: Color(0xFF4E7D28), + sell: Color(0xFFFF8B8B), + onSell: Color(0xFF2A1015), + yellow: Color(0xFFE0B72C), + yellowInk: Color(0xFF7A5D00), + premiumMid: Color(0xFF7A5A12), + premiumHigh: Color(0xFF9A4A0E), + textPrimary: Color(0xFF12161F), + textStrong: Color(0xFF1E2430), + textBody: Color(0xFF2A303C), + textMuted: Color(0xFF3F4756), + textSecondary: Color(0xFF5A6474), + textTertiary: Color(0xFF5F6979), + textFaint: Color(0xFF636D7D), + textNew: Color(0xFF4A5364), + sortLabel: Color(0xFF4A5364), + starEmpty: Color(0xFFA3ACBA), + divider: Color(0xFFC3CAD6), + notif: Color(0xFFE5484D), + tabTrack: Color(0x0F12161F), // ink 6% + tabActiveFill: Color(0x3392D64F), // lime 20% + tabActiveBorder: Color(0x665C9130), // rgba(92,145,48,0.40) + chipFill: Color(0xFFFFFFFF), + chipBorder: Color(0x1412161F), + currencyChipFill: Color(0x0F12161F), + bestChipFill: Color(0x3392D64F), + bestChipBorder: Color(0x665C9130), + reputableChipFill: Color(0x40F2D14B), // yellow 25% + reputableChipBorder: Color(0x73D8AF19), // rgba(216,175,25,0.45) + navBorder: Color(0x1412161F), + scrim: Color(0xD1080B10), + // The scrim darkens a white page far less than a dark one, so the hint + // needs a lighter tone than dark's to stay readable on it. + scrimText: Color(0xFFA6B0C2), + fabClose: Color(0xFF2A3244), + fabCloseBorder: Color(0x1AFFFFFF), + fabCloseIcon: Color(0xFFE4E9F2), + fabShadow: [ + BoxShadow( + color: Color(0x5992D64F), // lime 35% + offset: Offset(0, 10), + blurRadius: 24, + spreadRadius: -6, + ), + ], + buyShadow: [ + BoxShadow( + color: Color(0x6692D64F), // lime 40% + offset: Offset(0, 10), + blurRadius: 26, + spreadRadius: -8, + ), + ], + sellShadow: [ + BoxShadow( + color: Color(0x4DFF8B8B), // rgba(255,139,139,0.30) + offset: Offset(0, 10), + blurRadius: 26, + spreadRadius: -8, + ), + ], + ); + + static OrderBookPalette of(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? dark : light; +} diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index 94eadcfd..80a0740c 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -30,12 +30,76 @@ final currencyFilterProvider = StateProvider>((_) => []); final paymentMethodFilterProvider = StateProvider>((_) => []); /// Rating range filter. Default = full range. -final ratingFilterProvider = - StateProvider<({double min, double max})>((_) => defaultRatingRange); +final ratingFilterProvider = StateProvider<({double min, double max})>( + (_) => defaultRatingRange, +); /// Premium range filter. Default = full range. -final premiumRangeFilterProvider = - StateProvider<({double min, double max})>((_) => defaultPremiumRange); +final premiumRangeFilterProvider = StateProvider<({double min, double max})>( + (_) => defaultPremiumRange, +); + +/// Whether any filter currently narrows the order book. +final hasActiveOrderFiltersProvider = Provider((ref) { + return ref.watch(currencyFilterProvider).isNotEmpty || + ref.watch(paymentMethodFilterProvider).isNotEmpty || + ref.watch(ratingFilterProvider) != defaultRatingRange || + ref.watch(premiumRangeFilterProvider) != defaultPremiumRange; +}); + +/// Resets every order-book filter to its default. +void clearOrderFilters(WidgetRef ref) { + ref.read(currencyFilterProvider.notifier).state = const []; + ref.read(paymentMethodFilterProvider.notifier).state = const []; + ref.read(ratingFilterProvider.notifier).state = defaultRatingRange; + ref.read(premiumRangeFilterProvider.notifier).state = defaultPremiumRange; +} + +// ── Sort ────────────────────────────────────────────────────────────────────── + +/// Criteria the order book can be sorted by. +enum OrderSort { + /// Most recently published first. + newest, + + /// The premium most in the taker's favour first — see + /// [OrderItemTakerView.takerPremiumAdvantage]. + bestPremium, + + /// Highest maker rating first, then most trades. + bestReputation, +} + +/// Selected order-book sort. Newest first by default. +final orderSortProvider = StateProvider((_) => OrderSort.newest); + +/// Orders [OrderItem]s by [sort]. Every criterion falls back to newest first, +/// so orders with equal keys keep a meaningful order — `List.sort` is not +/// stable. +Comparator orderComparator(OrderSort sort) { + int newestFirst(OrderItem a, OrderItem b) => + b.createdAt.compareTo(a.createdAt); + int thenNewest(int byKey, OrderItem a, OrderItem b) => + byKey != 0 ? byKey : newestFirst(a, b); + + return switch (sort) { + OrderSort.newest => newestFirst, + OrderSort.bestPremium => + (a, b) => thenNewest( + b.takerPremiumAdvantage.compareTo(a.takerPremiumAdvantage), + a, + b, + ), + OrderSort.bestReputation => (a, b) { + final byRating = b.rating.compareTo(a.rating); + return thenNewest( + byRating != 0 ? byRating : b.tradeCount.compareTo(a.tradeCount), + a, + b, + ); + }, + }; +} // ── Order model ─────────────────────────────────────────────────────────────── @@ -60,12 +124,10 @@ class OrderItem { this.amountSats, this.isMine = false, }) { - final isFixed = fiatAmount != null && - fiatAmountMin == null && - fiatAmountMax == null; - final isRange = fiatAmount == null && - fiatAmountMin != null && - fiatAmountMax != null; + final isFixed = + fiatAmount != null && fiatAmountMin == null && fiatAmountMax == null; + final isRange = + fiatAmount == null && fiatAmountMin != null && fiatAmountMax != null; if (!isFixed && !isRange) { throw ArgumentError( 'OrderItem requires exactly one shape: ' @@ -88,10 +150,15 @@ class OrderItem { final double rating; final int tradeCount; final int daysActive; + /// Current order status from the Mostro protocol. final OrderStatus status; - /// Sats amount resolved by Mostro (non-null once Mostro accepts the take). + + /// Sats amount. On a published order it is the Kind 38383 `amt` tag: `0` + /// when the order is priced at market when taken, the fixed amount + /// otherwise. Once Mostro accepts a take it carries the resolved amount. final BigInt? amountSats; + /// True when this order was created by the current user. final bool isMine; @@ -136,51 +203,67 @@ class OrderItem { @override int get hashCode => Object.hashAll([ - id, - kind, - fiatAmount, - fiatAmountMin, - fiatAmountMax, - fiatCode, - paymentMethod, - premium, - creatorPubkey, - createdAt, - expiresAt, - rating, - tradeCount, - daysActive, - status, - amountSats, - isMine, - ]); + id, + kind, + fiatAmount, + fiatAmountMin, + fiatAmountMax, + fiatCode, + paymentMethod, + premium, + creatorPubkey, + createdAt, + expiresAt, + rating, + tradeCount, + daysActive, + status, + amountSats, + isMine, + ]); /// Map a Rust-bridge [OrderInfo] to an [OrderItem] for display. factory OrderItem.fromInfo(OrderInfo info) => OrderItem( - id: info.id, - kind: info.kind == OrderKind.buy ? 'buy' : 'sell', - fiatAmount: info.fiatAmount, - fiatAmountMin: info.fiatAmountMin, - fiatAmountMax: info.fiatAmountMax, - fiatCode: info.fiatCode, - paymentMethod: info.paymentMethod, - premium: info.premium, - creatorPubkey: info.creatorPubkey, - createdAt: DateTime.fromMillisecondsSinceEpoch( - platformInt64ToInt(info.createdAt) * 1000, - ), - expiresAt: info.expiresAt != null + id: info.id, + kind: info.kind == OrderKind.buy ? 'buy' : 'sell', + fiatAmount: info.fiatAmount, + fiatAmountMin: info.fiatAmountMin, + fiatAmountMax: info.fiatAmountMax, + fiatCode: info.fiatCode, + paymentMethod: info.paymentMethod, + premium: info.premium, + creatorPubkey: info.creatorPubkey, + createdAt: DateTime.fromMillisecondsSinceEpoch( + platformInt64ToInt(info.createdAt) * 1000, + ), + expiresAt: + info.expiresAt != null ? DateTime.fromMillisecondsSinceEpoch( - platformInt64ToInt(info.expiresAt!) * 1000, - ) + platformInt64ToInt(info.expiresAt!) * 1000, + ) : null, - status: info.status, - amountSats: info.amountSats, - isMine: info.isMine, - rating: info.rating, - tradeCount: info.totalReviews, - daysActive: info.daysActive, - ); + status: info.status, + amountSats: info.amountSats, + isMine: info.isMine, + rating: info.rating, + tradeCount: info.totalReviews, + daysActive: info.daysActive, + ); +} + +/// How an order reads from the side of whoever takes it. +extension OrderItemTakerView on OrderItem { + /// Premium points in the taker's favour — higher is always better. + /// + /// Taking a sell order means buying BTC, where a lower premium is cheaper; + /// taking a buy order means selling it, where a higher premium pays more. + /// (`0 - premium` rather than `-premium` so a zero premium stays `0.0`: + /// `-0.0` sorts below `0.0`.) + double get takerPremiumAdvantage => kind == 'sell' ? 0 - premium : premium; + + /// Whether the maker fixed the sats amount (`amt` > 0), as opposed to an + /// order priced at market when it is taken. + bool get hasFixedSats => (amountSats ?? BigInt.zero) > BigInt.zero; } /// Live order book backed by the Rust bridge Kind 38383 subscription. @@ -188,7 +271,9 @@ class OrderItem { /// Immediately yields the current cached snapshot (empty on first run) so the /// UI exits the shimmer/loading state right away. Subsequent emissions arrive /// as [subscribe_orders()] upserts orders from the relay stream. -final orderBookProvider = StreamProvider.autoDispose>((ref) async* { +final orderBookProvider = StreamProvider.autoDispose>(( + ref, +) async* { // Subscribe first so no broadcast is missed between snapshot and loop. final stream = await orders_api.onOrdersUpdated(); @@ -213,8 +298,11 @@ final orderBookProvider = StreamProvider.autoDispose>((ref) asyn /// /// Screens that care about a single order used to scan the whole list for it, /// on every rebuild — an O(orders) walk per screen per relay event. -final orderBookIndexProvider = Provider.autoDispose>((ref) { - final orders = ref.watch(orderBookProvider).valueOrNull ?? const []; +final orderBookIndexProvider = Provider.autoDispose>(( + ref, +) { + final orders = + ref.watch(orderBookProvider).valueOrNull ?? const []; return {for (final order in orders) order.id: order}; }); @@ -223,12 +311,35 @@ final orderBookIndexProvider = Provider.autoDispose>((ref /// The `select` is what makes this worth having: a screen watching one order /// rebuilds only when *that* order changes, not on every book emission. It /// relies on [OrderItem]'s value equality. -final orderByIdProvider = - Provider.autoDispose.family((ref, orderId) { +final orderByIdProvider = Provider.autoDispose.family(( + ref, + orderId, +) { return ref.watch(orderBookIndexProvider.select((index) => index[orderId])); }); -/// Filtered orders based on active tab and all filter providers. +/// Whether [order] belongs on [tab] before any filter applies. +/// +/// The book shows only pending orders, and "BUY BTC" lists sell orders (the +/// taker buys) while "SELL BTC" lists buy orders. Own orders follow the same +/// split as everyone else's — distinguished only by the "you are +/// selling/buying" pill (issue #290); managing them has its own place (My +/// Trades / MyOrderScreen on tap). +bool _isListedOnTab(OrderItem order, OrderType tab) => + order.status == OrderStatus.pending && + order.kind == (tab == OrderType.buy ? 'sell' : 'buy'); + +/// Whether the active tab has any order before filters apply — what tells +/// "the filters hide everything" apart from "there is nothing to show". +final tabHasOrdersProvider = Provider.autoDispose((ref) { + final orders = + ref.watch(orderBookProvider).valueOrNull ?? const []; + final tab = ref.watch(homeOrderTypeProvider); + return orders.any((order) => _isListedOnTab(order, tab)); +}); + +/// Filtered orders based on active tab, all filter providers and the selected +/// [OrderSort]. /// /// Unwraps the `AsyncValue` from [orderBookProvider]; returns `[]` while /// loading or on error so that filter/tab logic is always well-typed. @@ -248,47 +359,40 @@ final filteredOrdersProvider = Provider.autoDispose>((ref) { final selectedPaymentMethods = ref.watch(paymentMethodFilterProvider); final ratingRange = ref.watch(ratingFilterProvider); final premiumRange = ref.watch(premiumRangeFilterProvider); - - // "BUY BTC" tab shows sell orders (taker buys); "SELL BTC" shows buy orders. - final targetKind = orderType == OrderType.buy ? 'sell' : 'buy'; + final sort = ref.watch(orderSortProvider); return allOrders.where((o) { - // Order book shows only pending orders. - if (o.status != OrderStatus.pending) return false; - // Own orders follow the same tab split as everyone else's — a sell - // order lives in BUY BTC, a buy order in SELL BTC — distinguished only - // by the "you are selling/buying" pill (issue #290). Managing them has - // its own place (My Trades / MyOrderScreen on tap). - if (o.kind != targetKind) return false; - - if (selectedCurrencies.isNotEmpty && - !selectedCurrencies.contains(o.fiatCode)) { - return false; - } - - if (selectedPaymentMethods.isNotEmpty) { - final tokens = o.paymentMethod - .split(',') - .map((t) => t.trim().toLowerCase()) - .toSet(); - final selectedLower = - selectedPaymentMethods.map((pm) => pm.toLowerCase()).toSet(); - if (tokens.intersection(selectedLower).isEmpty) return false; - } + if (!_isListedOnTab(o, orderType)) return false; - if (ratingRange != defaultRatingRange) { - if (o.rating < ratingRange.min || o.rating > ratingRange.max) { + if (selectedCurrencies.isNotEmpty && + !selectedCurrencies.contains(o.fiatCode)) { return false; } - } - if (premiumRange != defaultPremiumRange) { - if (o.premium < premiumRange.min || o.premium > premiumRange.max) { - return false; + if (selectedPaymentMethods.isNotEmpty) { + final tokens = + o.paymentMethod + .split(',') + .map((t) => t.trim().toLowerCase()) + .toSet(); + final selectedLower = + selectedPaymentMethods.map((pm) => pm.toLowerCase()).toSet(); + if (tokens.intersection(selectedLower).isEmpty) return false; + } + + if (ratingRange != defaultRatingRange) { + if (o.rating < ratingRange.min || o.rating > ratingRange.max) { + return false; + } + } + + if (premiumRange != defaultPremiumRange) { + if (o.premium < premiumRange.min || o.premium > premiumRange.max) { + return false; + } } - } - return true; - }).toList() - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return true; + }).toList() + ..sort(orderComparator(sort)); }); diff --git a/lib/features/home/providers/order_reason_provider.dart b/lib/features/home/providers/order_reason_provider.dart index 516b8236..66bca7e8 100644 --- a/lib/features/home/providers/order_reason_provider.dart +++ b/lib/features/home/providers/order_reason_provider.dart @@ -2,40 +2,49 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; -/// "Reason to pick" badge for an order book card (UX proposal #3). +/// Highlight chip of an order-book card (order-book handoff, variant 4b). /// /// Each reason is awarded to at most one card in the visible list, and each -/// card carries at most one reason. Priority when a card qualifies for -/// several: best premium > most reputable > just published. -enum OrderReason { bestPremium, mostReputable, justPublished } +/// card carries at most one — a chip means something only because it is +/// rare. Priority when a card qualifies for both: best premium. +enum OrderReason { + /// The premium most in the taker's favour. The card also gets the + /// highlight border. + bestPremium, -/// How recent an order must be to qualify as "Just published". -const justPublishedWindow = Duration(minutes: 10); + /// The highest-rated maker among the other cards. + mostReputable, +} -/// Computes the reason badge for each order in [orders] (the currently +/// Computes the highlight chip for each order in [orders] (the currently /// displayed, filtered list). Returns a map of order id -> reason. /// /// Rules (deterministic): -/// - Best premium: the single order with the lowest premium. Ties broken by -/// list position (first wins). +/// - Best premium: the order with the highest +/// [OrderItemTakerView.takerPremiumAdvantage] — the cheapest sell order on +/// Buy BTC, the best-paying buy order on Sell BTC. Ties are broken by list +/// position (first wins). Awarded only when that order beats at least one +/// other: a lone order, or a list where every premium is equal, has no +/// "best". /// - Most reputable: the order with the highest rating (must be > 0), ties /// broken by higher tradeCount, then list position. Skips the card that /// already won "best premium". -/// - Just published: the most recently created order with -/// createdAt < 10 minutes ago, among cards not already awarded. -Map computeOrderReasons( - List orders, { - DateTime? now, -}) { +Map computeOrderReasons(List orders) { if (orders.isEmpty) return const {}; final reasons = {}; - // Best premium — lowest premium in the visible list. - OrderItem best = orders.first; + // Best premium — most in the taker's favour, if it beats anything. + var best = orders.first; + var beatsAnother = false; for (final o in orders.skip(1)) { - if (o.premium < best.premium) best = o; + if (o.takerPremiumAdvantage > best.takerPremiumAdvantage) { + best = o; + beatsAnother = true; + } else if (o.takerPremiumAdvantage < best.takerPremiumAdvantage) { + beatsAnother = true; + } } - reasons[best.id] = OrderReason.bestPremium; + if (beatsAnother) reasons[best.id] = OrderReason.bestPremium; // Most reputable — highest rating (> 0), ties broken by tradeCount. OrderItem? reputable; @@ -52,24 +61,10 @@ Map computeOrderReasons( reasons[reputable.id] = OrderReason.mostReputable; } - // Just published — newest order created < 10 minutes ago. - final cutoff = (now ?? DateTime.now()).subtract(justPublishedWindow); - OrderItem? fresh; - for (final o in orders) { - if (reasons.containsKey(o.id)) continue; - if (!o.createdAt.isAfter(cutoff)) continue; - if (fresh == null || o.createdAt.isAfter(fresh.createdAt)) { - fresh = o; - } - } - if (fresh != null) { - reasons[fresh.id] = OrderReason.justPublished; - } - return reasons; } -/// Reason badges for the currently displayed (filtered) order list. +/// Highlight chips for the currently displayed (filtered) order list. /// /// autoDispose for the same reason as [filteredOrdersProvider]: watching it /// from a provider that never disposes would keep the whole order-book diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 97658814..9adbc530 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -10,19 +12,40 @@ import 'package:mostro/features/drawer/screens/drawer_menu.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/home/providers/order_reason_provider.dart'; import 'package:mostro/features/home/widgets/order_book_list.dart'; -import 'package:mostro/features/home/widgets/order_list_item.dart'; -import 'package:mostro/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro/features/home/widgets/order_list_empty.dart'; +import 'package:mostro/features/home/widgets/order_sort_sheet.dart'; +import 'package:mostro/l10n/app_localizations.dart'; import 'package:mostro/shared/utils/fiat_currencies.dart'; -import 'package:mostro/shared/widgets/notification_bell.dart'; import 'package:mostro/shared/widgets/add_order_button.dart'; -import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro/shared/widgets/notification_bell.dart'; import 'package:mostro/shared/widgets/order_filter.dart'; -import 'package:mostro/shared/widgets/order_list_skeleton.dart'; +import 'package:mostro/features/home/widgets/order_list_skeleton.dart'; + +/// Side margin of every row on the screen (handoff 4b). +const double _sideInset = 18; + +/// Buy/Sell switch: the list cross-fades, with no slide. +const Duration _switchDuration = Duration(milliseconds: 150); + +/// Where tapping [order] leads: its own screen for an order of ours, the take +/// flow for its side otherwise. +/// +/// Decided by the order, never by the tab on screen: during the tab +/// cross-fade the outgoing list is still tappable while the tab already names +/// the other side. +@visibleForTesting +String routeForOrder(OrderItem order) { + if (order.isMine) return AppRoute.myOrderPath(order.id); + return order.kind == 'sell' + ? AppRoute.takeSellPath(order.id) + : AppRoute.takeBuyPath(order.id); +} -/// Home screen — public order book, pixel-exact port of the "Mostro UX -/// Redesign" mock (screen #3 · Order book with reasons to pick). +/// Home screen — the public order book (order-book handoff, variant 4b). /// -/// BUY/SELL tabs, FILTER pill + sort caption, offer cards, and drawer. +/// App bar, Buy/Sell segmented tabs, filter row with the order count and the +/// sort, the order cards, and the create-order button (variant 4d). class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @@ -30,222 +53,93 @@ class HomeScreen extends ConsumerStatefulWidget { ConsumerState createState() => _HomeScreenState(); } -class _HomeScreenState extends ConsumerState - with SingleTickerProviderStateMixin { +class _HomeScreenState extends ConsumerState { bool _drawerOpen = false; - late final TabController _tabController; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - ref.read(homeOrderTypeProvider.notifier).state = - _tabController.index == 0 ? OrderType.buy : OrderType.sell; - } - }); - } + void _toggleDrawer() => setState(() => _drawerOpen = !_drawerOpen); - @override - void dispose() { - _tabController.dispose(); - super.dispose(); + void _openOrder(String id) { + final allOrders = ref.read(orderBookProvider).valueOrNull ?? []; + final order = allOrders.where((o) => o.id == id).firstOrNull; + // Taken or cancelled between the frame that showed it and the tap. + if (order == null) return; + context.push(routeForOrder(order)); } - void _toggleDrawer() => setState(() => _drawerOpen = !_drawerOpen); - @override Widget build(BuildContext context) { final theme = Theme.of(context); final pal = OrderBookPalette.of(context); - final l10n = AppLocalizations.of(context); final filteredOrders = ref.watch(filteredOrdersProvider); - // "Reason to pick" badges computed once per visible list (not per card). + // Highlight chips computed once per visible list (not per card). final orderReasons = ref.watch(orderReasonsProvider); final flags = ref.watch(currencyFlagsProvider); + final orderType = ref.watch(homeOrderTypeProvider); + final sort = ref.watch(orderSortProvider); final screenWidth = MediaQuery.sizeOf(context).width; final isDesktop = screenWidth >= AppBreakpoints.desktop; // ── Order list: responsive column count ────────────────────────────────── final columns = - screenWidth >= AppBreakpoints.desktop + isDesktop ? 3 : screenWidth >= AppBreakpoints.tablet ? 2 : 1; - Widget orderContent(void Function(String orderId, OrderType type) onTap) { - if (filteredOrders.isEmpty) return const OrderListEmpty(); - return OrderBookList( - orders: filteredOrders, - currencyFlags: flags, - reasons: orderReasons, - columns: columns, - onOrderTap: (id) => onTap(id, ref.read(homeOrderTypeProvider)), - ); - } - - void onOrderTap(String id, OrderType type) { - final allOrders = ref.read(orderBookProvider).valueOrNull ?? []; - final order = allOrders.where((o) => o.id == id).firstOrNull; - if (order?.isMine == true) { - context.push(AppRoute.myOrderPath(id)); - } else if (type == OrderType.buy) { - context.push(AppRoute.takeSellPath(id)); - } else { - context.push(AppRoute.takeBuyPath(id)); - } - } + // Shimmer while loading, error state, empty state, or the live list. + final book = ref.watch(orderBookProvider); + final orders = book.when( + loading: () => const OrderListSkeleton(), + error: + (_, __) => _OrderBookError( + palette: pal, + onRetry: () => ref.invalidate(orderBookProvider), + ), + data: + (_) => + filteredOrders.isEmpty + ? const OrderListEmpty() + : OrderBookList( + orders: filteredOrders, + currencyFlags: flags, + reasons: orderReasons, + columns: columns, + onOrderTap: _openOrder, + ), + ); // ── Main content column ─────────────────────────────────────────────────── final mainContent = Column( children: [ - // AppBar (hidden hamburger on desktop — sidebar is always visible) - SafeArea( - bottom: false, - child: _MostroAppBar( - palette: pal, - onMenuTap: isDesktop ? null : _toggleDrawer, - ), + _OrderBookAppBar( + palette: pal, + onMenuTap: isDesktop ? null : _toggleDrawer, ), - - // Tabs — active: green 2px underline; inactive: disabled over 1px rule. - Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: pal.border)), - ), - child: TabBar( - controller: _tabController, - indicatorColor: pal.green, - indicatorWeight: 2, - indicatorSize: TabBarIndicatorSize.tab, - dividerColor: Colors.transparent, - labelColor: pal.green, - unselectedLabelColor: pal.tabInactive, - labelStyle: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 1, - ), - // Named by the visible tab, not by what it filters: the Buy BTC - // tab lists sell orders, so deriving the id from the filtered - // OrderType would swap the two. - tabs: [ - Tab( - child: Text(l10n.tabBuyBtc) - .withAutomationId(AutomationIds.orderBookTabBuy), - ), - Tab( - child: Text(l10n.tabSellBtc) - .withAutomationId(AutomationIds.orderBookTabSell), - ), - ], - ), + _SideTabs( + palette: pal, + selected: orderType, + onSelected: + (type) => ref.read(homeOrderTypeProvider.notifier).state = type, ), - - // Filter row: FILTER pill + offer count · sort caption - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), - child: Row( - children: [ - // Flexible + ellipsis so long localized labels (de/fr) fit - // 320px-wide screens without a RenderFlex overflow. - Flexible( - child: Material( - // bgElevated, not bgCard: with the v1 recipe bgCard equals - // the page tone, which would make the pill invisible (v1's - // filter uses its lighter input tone for the same reason). - color: pal.bgElevated, - shape: StadiumBorder(side: BorderSide(color: pal.border)), - child: InkWell( - customBorder: const StadiumBorder(), - onTap: () => showOrderFilterDialog(context), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.filter_alt_outlined, - size: 16, - color: pal.textSecondary, - ), - const SizedBox(width: 8), - Text( - l10n.filterButtonLabel, - style: TextStyle( - color: pal.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - '· ${l10n.offersCount(filteredOrders.length)}', - style: TextStyle( - color: pal.textTertiary, - fontSize: 13, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.sortNewest, - style: TextStyle(fontSize: 11, color: pal.textTertiary), - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - ), - ), - ], - ), + _FilterRow( + palette: pal, + count: filteredOrders.length, + sort: sort, + // Loading or failed: there is no book yet to count or to sort. + showsOrders: book.hasValue, ), - - // Order list — shimmer while loading, error state, or live data. - // The well is one step lighter than the chrome (v1's `dark1` - // container): the cards share the chrome's tone, so this inverted - // contrast is what makes them read as panels. Expanded( - child: ColoredBox( - color: pal.bgWell, - child: ref - .watch(orderBookProvider) - .when( - loading: () => const OrderListSkeleton(), - error: - (e, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.errorLoadingOrders, - style: TextStyle(color: pal.textSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: AppSpacing.md), - TextButton( - onPressed: - () => ref.invalidate(orderBookProvider), - child: Text(l10n.retry), - ), - ], - ), - ), - data: (_) => orderContent(onOrderTap), + child: AnimatedSwitcher( + duration: _switchDuration, + layoutBuilder: + (current, previous) => Stack( + fit: StackFit.expand, + children: [...previous, if (current != null) current], ), + // Keyed by side only: a book update within the same tab rebuilds + // the list in place instead of fading it. + child: KeyedSubtree(key: ValueKey(orderType), child: orders), ), ), ], @@ -274,8 +168,7 @@ class _HomeScreenState extends ConsumerState ); // The scaffold background is overridden at the theme level so shared - // chrome that reads scaffoldBackgroundColor (bottom nav) matches the - // mock's phone background with no seam. + // chrome that reads scaffoldBackgroundColor matches the page with no seam. return Theme( data: theme.copyWith(scaffoldBackgroundColor: pal.bg), child: Scaffold( @@ -288,47 +181,337 @@ class _HomeScreenState extends ConsumerState } } -/// Custom app bar per the mock: hamburger left, Mostro logo centered, -/// notification bell right, 52px tall over a 1px hairline. -class _MostroAppBar extends StatelessWidget { - const _MostroAppBar({required this.palette, required this.onMenuTap}); +// ── App bar ─────────────────────────────────────────────────────────────────── + +/// Hamburger left, mascot centred, notification bell right. +class _OrderBookAppBar extends StatelessWidget { + const _OrderBookAppBar({required this.palette, required this.onMenuTap}); final OrderBookPalette palette; - /// Null on desktop where the persistent sidebar replaces the overlay drawer. + /// Null on desktop, where the persistent sidebar replaces the overlay drawer. final VoidCallback? onMenuTap; + /// Material's minimum touch target. + static const double _target = 48; + + /// Space between a 48-dp target and its 22-dp glyph, taken out of the + /// mock's paddings so the glyphs — not the targets — sit where it puts them. + static const double _glyphInset = (_target - 22) / 2; + @override Widget build(BuildContext context) { - return Column( - children: [ - SizedBox( - height: 52, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Stack( - alignment: Alignment.center, + // The mock's 44 includes the status bar; below a taller one keep 12. + final top = math.max(44.0, MediaQuery.paddingOf(context).top + 12); + + return Padding( + padding: EdgeInsets.fromLTRB( + _sideInset - _glyphInset, + top - _glyphInset, + _sideInset - _glyphInset, + // The target reaches 1 dp past the mock's 12 below the glyph. + math.max(0, 12 - _glyphInset), + ), + child: SizedBox( + height: _target, + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + 'assets/images/mostro_mascot.webp', + height: 26, + excludeFromSemantics: true, + ), + Row( children: [ - Image.asset('assets/images/mostro_logo.webp', height: 32), - Row( - children: [ - if (onMenuTap != null) - IconButton( - onPressed: onMenuTap, - iconSize: 22, - icon: Icon(Icons.menu, color: palette.textPrimary), - tooltip: AppLocalizations.of(context).menuTooltip, - ).withAutomationId(AutomationIds.appBarDrawer), - const Spacer(), - const NotificationBell(), - ], + if (onMenuTap != null) + IconButton( + onPressed: onMenuTap, + style: IconButton.styleFrom( + minimumSize: const Size.square(_target), + padding: const EdgeInsets.all(_glyphInset), + ), + iconSize: 22, + icon: Icon(Icons.menu_rounded, color: palette.textBody), + tooltip: AppLocalizations.of(context).menuTooltip, + ).withAutomationId(AutomationIds.appBarDrawer), + const Spacer(), + NotificationBell( + iconColor: palette.textBody, + iconSize: 22, + dotColor: palette.notif, + dotRingColor: palette.bg, ), ], ), + ], + ), + ), + ); + } +} + +// ── Buy / Sell ──────────────────────────────────────────────────────────────── + +/// Pill-shaped segmented control. The Buy BTC tab lists sell orders (the +/// taker buys) and vice versa, so the automation ids follow the visible +/// label, not the side they filter. +class _SideTabs extends StatelessWidget { + const _SideTabs({ + required this.palette, + required this.selected, + required this.onSelected, + }); + + final OrderBookPalette palette; + final OrderType selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: _sideInset), + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.tabTrack, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + children: [ + Expanded( + child: _SideTab( + label: l10n.tabBuyBtc, + isSelected: selected == OrderType.buy, + palette: palette, + onTap: () => onSelected(OrderType.buy), + ).withAutomationId(AutomationIds.orderBookTabBuy), + ), + const SizedBox(width: 4), + Expanded( + child: _SideTab( + label: l10n.tabSellBtc, + isSelected: selected == OrderType.sell, + palette: palette, + onTap: () => onSelected(OrderType.sell), + ).withAutomationId(AutomationIds.orderBookTabSell), + ), + ], + ), + ), + ), + ); + } +} + +class _SideTab extends StatelessWidget { + const _SideTab({ + required this.label, + required this.isSelected, + required this.palette, + required this.onTap, + }); + + final String label; + final bool isSelected; + final OrderBookPalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + const radius = BorderRadius.all(Radius.circular(999)); + + return Semantics( + selected: isSelected, + inMutuallyExclusiveGroup: true, + // The fill sits under the Material and the InkWell on it, so the ripple + // paints over the active tab's tint instead of beneath it. + child: AnimatedContainer( + duration: _switchDuration, + // Both halves carry the 1px border (transparent when inactive) so + // switching does not shift their height. + decoration: BoxDecoration( + color: isSelected ? palette.tabActiveFill : Colors.transparent, + borderRadius: radius, + border: Border.all( + color: isSelected ? palette.tabActiveBorder : Colors.transparent, + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? palette.limeInk : palette.textSecondary, + ), + ), + ), ), ), - Container(height: 1, color: palette.border), - ], + ), + ); + } +} + +// ── Filter row ──────────────────────────────────────────────────────────────── + +/// "Filter" chip + order count · current sort, which opens the sort picker. +class _FilterRow extends StatelessWidget { + const _FilterRow({ + required this.palette, + required this.count, + required this.sort, + required this.showsOrders, + }); + + final OrderBookPalette palette; + final int count; + final OrderSort sort; + + /// Whether the book has loaded. Until it has, only the filter chip shows: + /// "0 orders" and a sort picker over nothing would both be misleading. + final bool showsOrders; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.fromLTRB(_sideInset, 14, _sideInset, 12), + child: Row( + children: [ + Flexible( + child: Material( + color: palette.chipFill, + shape: StadiumBorder(side: BorderSide(color: palette.chipBorder)), + child: InkWell( + customBorder: const StadiumBorder(), + onTap: () => showOrderFilterDialog(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 7, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.filter_alt_outlined, + size: 14, + color: palette.limeIcon, + ), + const SizedBox(width: 7), + Flexible( + child: Text( + l10n.filterButtonLabel, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: palette.textStrong, + ), + ), + ), + ], + ), + ), + ), + ), + ), + if (showsOrders) ...[ + const SizedBox(width: 8), + // The whole word, never "15 o…": on a very narrow screen it wraps. + Flexible( + child: Text( + l10n.ordersCount(count), + style: TextStyle(fontSize: 12, color: palette.textTertiary), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () => showOrderSortSheet(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + orderSortLabel(l10n, sort), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: palette.sortLabel, + ), + ), + ), + const SizedBox(width: 5), + Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: palette.sortLabel, + ), + ], + ), + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +// ── Error state ─────────────────────────────────────────────────────────────── + +class _OrderBookError extends StatelessWidget { + const _OrderBookError({required this.palette, required this.onRetry}); + + final OrderBookPalette palette; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.errorLoadingOrders, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: palette.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom(foregroundColor: palette.limeText), + child: Text(l10n.retry), + ), + ], + ), + ), ); } } diff --git a/lib/features/home/widgets/order_book_list.dart b/lib/features/home/widgets/order_book_list.dart index 97d9963a..eeed29bf 100644 --- a/lib/features/home/widgets/order_book_list.dart +++ b/lib/features/home/widgets/order_book_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/home/providers/order_reason_provider.dart'; @@ -20,16 +21,20 @@ class OrderBookList extends StatelessWidget { required this.onOrderTap, }); - /// Already filtered and sorted — newest first, which is what makes an - /// arriving order shift every row below it. + /// Already filtered and sorted — newest first by default, which is what + /// makes an arriving order shift every row below it. final List orders; final Map currencyFlags; final Map reasons; final int columns; final void Function(String orderId) onOrderTap; - /// Mock list: 8px top, 16px sides, 90px bottom clearance, 12px card gap. - static const _listPadding = EdgeInsets.fromLTRB(16, 8, 16, 90); + /// Handoff 4b: 18 at the sides, cards straight under the filter row, and + /// enough bottom clearance for the last card to scroll out from under the + /// create-order button. Shared with the loading skeleton. + static const listPadding = EdgeInsets.fromLTRB(18, 0, 18, 96); + + static const double _gap = 12; @override Widget build(BuildContext context) { @@ -48,17 +53,15 @@ class OrderBookList extends StatelessWidget { Map? indexById; int? indexOfKey(Key key) { if (key is! ValueKey) return null; - indexById ??= { - for (var i = 0; i < orders.length; i++) orders[i].id: i, - }; + indexById ??= {for (var i = 0; i < orders.length; i++) orders[i].id: i}; return indexById![key.value]; } if (columns == 1) { return ListView.separated( - padding: _listPadding, + padding: listPadding, itemCount: orders.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), + separatorBuilder: (_, __) => const SizedBox(height: _gap), // `separated` builds one child per item *and* one per separator, so a // child index is twice its item index — hence the doubling, which is // the whole reason Flutter later replaced this parameter with @@ -76,17 +79,28 @@ class OrderBookList extends StatelessWidget { ); } - return GridView.builder( - padding: _listPadding, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.1, - ), - itemCount: orders.length, - findChildIndexCallback: indexOfKey, - itemBuilder: (context, index) => _card(orders[index]), + // Masonry, not a fixed-extent grid: a card's height depends on its + // content and the text scale (wrapping chips, two lines of payment + // methods), so a fixed tile ratio overflows on long localized copy or + // large text. The same index callback keeps rows moving on a re-sort. + return CustomScrollView( + slivers: [ + SliverPadding( + padding: listPadding, + sliver: SliverMasonryGrid( + gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + ), + mainAxisSpacing: _gap, + crossAxisSpacing: _gap, + delegate: SliverChildBuilderDelegate( + (context, index) => _card(orders[index]), + childCount: orders.length, + findChildIndexCallback: indexOfKey, + ), + ), + ), + ], ); } diff --git a/lib/features/home/widgets/order_list_empty.dart b/lib/features/home/widgets/order_list_empty.dart new file mode 100644 index 00000000..3da1ddef --- /dev/null +++ b/lib/features/home/widgets/order_list_empty.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +/// Empty order book: the faded mascot, a title, and one line that says why. +/// +/// It blames the filters — and offers to clear them — only when they are +/// what hides the orders: when the tab has none at all, clearing the filters +/// would change nothing, so it says the generic line instead. +class OrderListEmpty extends ConsumerWidget { + const OrderListEmpty({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pal = OrderBookPalette.of(context); + final l10n = AppLocalizations.of(context); + final isFiltered = ref.watch(hasActiveOrderFiltersProvider); + final tabHasOrders = ref.watch(tabHasOrdersProvider); + final hiddenByFilters = isFiltered && tabHasOrders; + + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/mostro_mascot.webp', + height: 64, + opacity: const AlwaysStoppedAnimation(0.4), + excludeFromSemantics: true, + ), + const SizedBox(height: 16), + Text( + l10n.noOrdersAvailable, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: pal.textBody, + ), + ), + const SizedBox(height: 6), + Text( + hiddenByFilters + ? l10n.ordersEmptyFilteredHint + : l10n.ordersEmptyHint, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: pal.textSecondary), + ), + if (hiddenByFilters) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () => clearOrderFilters(ref), + style: TextButton.styleFrom(foregroundColor: pal.limeText), + child: Text(l10n.clearFiltersButton), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index f54f4368..90066e3c 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -9,51 +9,84 @@ import 'package:mostro/features/home/providers/home_order_providers.dart'; import 'package:mostro/features/home/providers/order_reason_provider.dart'; import 'package:mostro/l10n/app_localizations.dart'; +const _cardRadius = BorderRadius.all(Radius.circular(18)); + +/// Vertical gap between the card's rows. +const double _rowGap = 12; + +/// Premium points against the taker beyond which a premium reads as +/// expensive rather than merely above market (handoff 4b: `> 3%`). +const double _premiumHighThreshold = 3; + /// The number formatters an order card needs, built once per locale. /// -/// The card needs four, and building them inline meant four `NumberFormat` -/// allocations per visible row on every rebuild — and the order book rebuilds -/// on every relay event. +/// Building them inline meant several `NumberFormat` allocations per visible +/// row on every rebuild — and the order book rebuilds on every relay event. /// /// Not because construction is dramatically more expensive than formatting: it /// parses the pattern and reads locale data, but measured against warm locale -/// data it costs about 1.2–1.5× a `format()` call (~1 µs each, ~4 µs per card). -/// It is worth doing for the allocations it stops making, and the honest size -/// of the win is small — the claim that construction dominates does not hold. +/// data it costs about 1.2–1.5× a `format()` call (~1 µs each). It is worth +/// doing for the allocations it stops making, and the honest size of the win +/// is small — the claim that construction dominates does not hold. class OrderCardFormats { OrderCardFormats._(String locale) - : premium = NumberFormat('+0.0;-0.0', locale), - rating = NumberFormat('0.##', locale), - decimal = NumberFormat.decimalPattern(locale); + : premium = NumberFormat('+0.0;-0.0', locale), + _unsignedPremium = NumberFormat('0.0', locale), + rating = NumberFormat('0.##', locale), + decimal = NumberFormat.decimalPattern(locale), + fiat = NumberFormat('#,##0.##', locale); - /// Premium pill, always signed: `+2.5` / `-2.5`. + /// Premium, always signed: `+2.5` / `-2.5`. final NumberFormat premium; - /// Raw ratings (4.9, 4.95): up to 2 decimals, no trailing zeros. + final NumberFormat _unsignedPremium; + + /// Raw ratings (4.9, 4.78): up to 2 decimals, no trailing zeros. final NumberFormat rating; - /// Grouped integers for the reputation strip (trade count, days active). + /// Grouped integers: sats, trade count, days active. final NumberFormat decimal; + /// Fiat amounts: grouped, up to two decimals, no trailing zeros. + final NumberFormat fiat; + static final Map _byLocale = {}; /// Formatters for [locale]. The app ships a handful of locales, so the /// cache needs no eviction. static OrderCardFormats of(String locale) => _byLocale.putIfAbsent(locale, () => OrderCardFormats._(locale)); + + /// [value] at the one-decimal precision the card shows, so what reads as + /// `0.0%` is also coloured as zero. + static double shown(double value) => (value * 10).roundToDouble() / 10; + + /// `+5.0%`, `0.0%`, `-1.5%` — zero carries no sign. + String premiumPercent(double value) { + final rounded = shown(value); + final text = + rounded == 0 ? _unsignedPremium.format(0) : premium.format(rounded); + return '$text%'; + } + + /// `832 – 5,000` for a range, `25` for a single amount. + String amount(OrderItem order) => + order.isRange + ? '${fiat.format(order.fiatAmountMin!)} – ' + '${fiat.format(order.fiatAmountMax!)}' + : fiat.format(order.fiatAmount!); } -/// Order list item card — pixel-exact port of the "Mostro UX Redesign" mock -/// (screen #3 · Order book with reasons to pick). +/// Order-book card (order-book handoff, variant 4b). /// -/// Layout, colors, and proportions mirror the mock's offer card: -/// reason pill + timestamp, 26px amount row with premium pill, "Market price" -/// caption, elevated numeric-reputation strip, and payment-method line. -/// Every card carries the palette's 9% hairline border; a [highlighted] card -/// swaps it for the green glow ring (at most one per screen — the selected / -/// action-required card). -/// Each card may carry one [OrderReason] pill (computed once per visible list -/// and passed in — never computed here). +/// Rows: currency chip + highlight chip + relative time · amount and its +/// caption beside the premium · payment methods (up to two lines) · +/// reputation strip. The premium is plain coloured text — pills are reserved +/// for the highlight chips, which appear once per list — and its colour is +/// read from the taker's side: green in their favour or at market, amber up +/// to 3 points against them, orange beyond. +/// +/// Nothing in the card has a fixed height, so it grows with the text scale. class OrderListItem extends StatelessWidget { const OrderListItem({ super.key, @@ -61,61 +94,93 @@ class OrderListItem extends StatelessWidget { this.onTap, this.currencyFlags = const {}, this.reason, - this.highlighted = false, }); final OrderItem order; final VoidCallback? onTap; final Map currencyFlags; - /// "Reason to pick" badge awarded to this card, if any. Computed across the - /// visible list (see [orderReasonsProvider]) and passed in by the screen. + /// Highlight chip awarded to this card, if any. Computed across the visible + /// list (see [orderReasonsProvider]) and passed in by the screen. The + /// best-premium card also gets the highlight border. final OrderReason? reason; - /// Marks this card as the screen's selected / action-required one: - /// green [OrderBookPalette.glowBorder] + [OrderBookPalette.glowRing] - /// instead of the plain hairline. Callers must set it on at most one - /// card per screen — if all cards glow, none stands out. - final bool highlighted; - @override Widget build(BuildContext context) { final pal = OrderBookPalette.of(context); - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toString(); - final formats = OrderCardFormats.of(locale); - final flag = currencyFlags[order.fiatCode] ?? ''; - - // Premium pill: green < 2 (incl. negative), amber 2–5, red > 5. - final premiumColor = - order.premium < 2 - ? pal.green - : order.premium > 5 - ? pal.red - : pal.amber; - final premiumText = '${formats.premium.format(order.premium)}%'; - - final (reasonLabel, reasonColor, reasonBg) = switch (reason) { - OrderReason.bestPremium => ( - l10n.reasonBestPremium, - pal.green, - pal.greenDim, - ), - OrderReason.mostReputable => ( - l10n.reasonMostReputable, - pal.gold, - pal.goldDim, + final formats = OrderCardFormats.of( + Localizations.localeOf(context).toString(), + ); + final isBestPremium = reason == OrderReason.bestPremium; + + // Material + InkWell (not GestureDetector) so each card is focusable, + // keyboard-activatable and announced as a button; the border rides the + // Material shape so the ripple clips to it. The card wraps exactly one + // tap target, so merging is what keeps the tap action on the node that + // carries the identifier. + return Material( + color: pal.surface, + shape: RoundedRectangleBorder( + borderRadius: _cardRadius, + side: BorderSide( + color: isBestPremium ? pal.borderHighlight : pal.border, + ), ), - OrderReason.justPublished => ( - l10n.reasonJustPublished, - pal.blue, - pal.blueFill, + child: InkWell( + onTap: onTap, + borderRadius: _cardRadius, + highlightColor: pal.pressed, + splashColor: pal.pressed, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeaderRow( + order: order, + reason: reason, + flag: currencyFlags[order.fiatCode] ?? '', + palette: pal, + ), + const SizedBox(height: _rowGap), + _AmountRow(order: order, formats: formats, palette: pal), + const SizedBox(height: _rowGap), + _PaymentMethodsRow( + paymentMethod: order.paymentMethod, + palette: pal, + ), + const SizedBox(height: _rowGap), + _ReputationStrip(order: order, formats: formats, palette: pal), + ], + ), + ), ), - null => (null, null, null), - }; + ).withAutomationId(AutomationIds.orderBookItem(order.id)); + } +} - // The mock's cards carry no buy/sell pill (the tabs already scope the - // side); the only functional signal kept is "yours" on own orders. +// ── Row 1: chips and time ───────────────────────────────────────────────────── + +class _HeaderRow extends StatelessWidget { + const _HeaderRow({ + required this.order, + required this.reason, + required this.flag, + required this.palette, + }); + + final OrderItem order; + final OrderReason? reason; + final String flag; + final OrderBookPalette palette; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + // The card carries no buy/sell chip (the tabs already scope the side); + // the only functional signal kept is "yours" on own orders. final mineLabel = order.isMine ? (order.kind == 'sell' @@ -123,338 +188,440 @@ class OrderListItem extends StatelessWidget { : l10n.orderPillYouAreBuying) : null; - // Material + InkWell (not GestureDetector) so each offer card is - // focusable, keyboard-activatable, and announced as a button. The depth - // shadow (and the glow ring) live on an outer DecoratedBox because - // Material shapes clip shadows; the border rides the Material shape so - // InkWell clips to it. With bgCard == bg the shadow is what separates - // the card from the page. - // The card wraps exactly one tap target, so merging is what keeps the tap - // action on the node that carries the identifier. - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - boxShadow: highlighted - ? [...pal.glowRing, ...pal.cardShadow] - : pal.cardShadow, + final highlight = switch (reason) { + OrderReason.bestPremium => _Chip( + label: l10n.reasonBestPremium, + color: palette.limeInk, + fill: palette.bestChipFill, + border: palette.bestChipBorder, ), - child: Material( - color: pal.bgCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide(color: highlighted ? pal.glowBorder : pal.border), - ), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Row 1: reason pill (+ "yours" pill) · relative timestamp. - // Pills keep their intrinsic width and wrap to a second run - // when they don't fit beside the timestamp — shrinking them - // ellipsized the labels on small phones ("ESTÁS COMP…"). - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 4, - children: [ - // Own-order pill first: on an own order it must - // always be readable, so the reason badge is the - // one that drops to the next run when they don't - // fit together. - if (mineLabel != null) - _Pill( - label: mineLabel, - color: pal.textSecondary, - background: pal.bgElevated, - ), - if (reasonLabel != null) - _Pill( - label: reasonLabel, - color: reasonColor!, - background: reasonBg!, - ), - ], - ), - ), - const SizedBox(width: 6), - Text( - _relativeTime(order.createdAt, l10n), - style: TextStyle(fontSize: 11, color: pal.textTertiary), - ), - ], - ), - const SizedBox(height: 10), + OrderReason.mostReputable => _Chip( + label: l10n.reasonMostReputable, + color: palette.yellowInk, + fill: palette.reputableChipFill, + border: palette.reputableChipBorder, + ), + null => null, + }; - // Row 2: amount + currency + flag · premium pill - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + return LayoutBuilder( + builder: + (context, constraints) => Row( + children: [ + // Chips keep their intrinsic width and wrap to a second run when + // they don't fit beside the time — shrinking them ellipsized the + // labels on small phones. + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Flexible( - child: Text( - order.displayAmount, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: pal.textPrimary, - height: 1.2, - ), - overflow: TextOverflow.ellipsis, - ), + _CurrencyChip( + flag: flag, + code: order.fiatCode, + palette: palette, ), - const SizedBox(width: 8), - Text( - order.fiatCode, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: pal.textSecondary, + // Own-order chip before the highlight: on an own order it + // must always be readable. + if (mineLabel != null) + _Chip( + label: mineLabel, + color: palette.textSecondary, + fill: palette.currencyChipFill, + border: palette.border, ), - ), - if (flag.isNotEmpty) ...[ - const SizedBox(width: 8), - Text(flag, style: const TextStyle(fontSize: 18)), - ], - const Spacer(), - _Pill( - label: premiumText, - color: premiumColor, - background: premiumColor.withValues(alpha: 0.13), - ), + if (highlight != null) highlight, ], ), - const SizedBox(height: 4), - - // Row 3: "Market price" caption - Text( - l10n.marketPriceCaption, - style: TextStyle(fontSize: 11, color: pal.textTertiary), - ), - const SizedBox(height: 10), - - // Row 4: numeric reputation — ★ 4.9 · 47 trades · 312 days - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: pal.bgElevated, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon(Icons.star, size: 16, color: pal.gold), - const SizedBox(width: 4), - Text( - formats.rating.format(order.rating), - style: TextStyle( - color: pal.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 14), - Text( - '·', - style: TextStyle(fontSize: 12, color: pal.textTertiary), - ), - const SizedBox(width: 14), - _StatText( - value: formats.decimal.format(order.tradeCount), - label: l10n.reputationTradesLabel(order.tradeCount), - palette: pal, - ), - const SizedBox(width: 14), - Text( - '·', - style: TextStyle(fontSize: 12, color: pal.textTertiary), - ), - const SizedBox(width: 14), - Flexible( - child: _StatText( - value: formats.decimal.format(order.daysActive), - label: l10n.reputationDaysLabel(order.daysActive), - palette: pal, - ), - ), - ], - ), + ), + const SizedBox(width: 8), + // Flush right at its natural width, capped at half the row so a + // long localized time at a large text scale wraps instead of + // pushing the chips out. + ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2), + child: Text( + _relativeTime(order.createdAt, l10n), + textAlign: TextAlign.end, + style: TextStyle(fontSize: 11, color: palette.textFaint), ), - const SizedBox(height: 8), - - // Row 5: payment methods - Text( - order.paymentMethod, - style: TextStyle(fontSize: 12, color: pal.textSecondary), - overflow: TextOverflow.ellipsis, - ), - ], - ), + ), + ], ), - ), - ), - ).withAutomationId(AutomationIds.orderBookItem(order.id)); + ); } +} - String _relativeTime(DateTime dt, AppLocalizations l10n) { - final diff = clock.now().difference(dt); - if (diff.isNegative || diff.inMinutes < 1) return l10n.justNow; - if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); - if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); - return l10n.daysAgo(diff.inDays); - } +String _relativeTime(DateTime dt, AppLocalizations l10n) { + final diff = clock.now().difference(dt); + if (diff.isNegative || diff.inMinutes < 1) return l10n.justNow; + if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); + if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); + return l10n.daysAgo(diff.inDays); } -/// Bold-number + secondary-label stat ("47 trades") from the reputation strip. -class _StatText extends StatelessWidget { - const _StatText({ - required this.value, - required this.label, +class _CurrencyChip extends StatelessWidget { + const _CurrencyChip({ + required this.flag, + required this.code, required this.palette, }); - final String value; - final String label; + final String flag; + final String code; final OrderBookPalette palette; @override Widget build(BuildContext context) { - return Text.rich( - TextSpan( + return Container( + padding: const EdgeInsets.fromLTRB(7, 4, 9, 4), + decoration: BoxDecoration( + color: palette.currencyChipFill, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - TextSpan( - text: value, - style: TextStyle( - color: palette.textPrimary, - fontWeight: FontWeight.w600, + if (flag.isNotEmpty) ...[ + Text(flag, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + code, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: palette.textStrong, + ), ), ), - TextSpan(text: ' $label'), ], ), - style: TextStyle(fontSize: 12, color: palette.textSecondary), - overflow: TextOverflow.ellipsis, ); } } -/// Small rounded pill — mock's `sm` variant (3×8 padding, radius 8, 11px/600). -class _Pill extends StatelessWidget { - const _Pill({ +/// Uppercase capsule — the highlight chips and the own-order chip. +class _Chip extends StatelessWidget { + const _Chip({ required this.label, required this.color, - required this.background, + required this.fill, + required this.border, }); final String label; final Color color; - final Color background; + final Color fill; + final Color border; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(8), + color: fill, + border: Border.all(color: border), + borderRadius: BorderRadius.circular(999), ), child: Text( - label, + label.toUpperCase(), + semanticsLabel: label, style: TextStyle( - color: color, - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w600, - letterSpacing: 0.2, + letterSpacing: 0.4, + color: color, ), - overflow: TextOverflow.ellipsis, ), ); } } -/// Skeleton shimmer placeholder for loading state. -class OrderListItemSkeleton extends StatelessWidget { - const OrderListItemSkeleton({super.key}); +// ── Row 2: amount and premium ───────────────────────────────────────────────── + +class _AmountRow extends StatelessWidget { + const _AmountRow({ + required this.order, + required this.formats, + required this.palette, + }); + + final OrderItem order; + final OrderCardFormats formats; + final OrderBookPalette palette; + + /// Green in the taker's favour or at market, amber up to 3 points against + /// them, orange beyond. + Color get _premiumColor { + final against = -OrderCardFormats.shown(order.takerPremiumAdvantage); + if (against <= 0) return palette.limeText; + if (against <= _premiumHighThreshold) return palette.premiumMid; + return palette.premiumHigh; + } @override Widget build(BuildContext context) { - final pal = OrderBookPalette.of(context); - - return Container( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - decoration: BoxDecoration( - color: pal.bgCard, - border: Border.all(color: pal.border), - borderRadius: BorderRadius.circular(20), - boxShadow: pal.cardShadow, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + final l10n = AppLocalizations.of(context); + final captionStyle = TextStyle(fontSize: 11, color: palette.textTertiary); + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _shimmerBox(100, 18, pal.bgElevated), - _shimmerBox(40, 14, pal.bgElevated), + // Never truncated: a long range wraps at the spaces around + // the dash instead. + Text( + formats.amount(order), + style: TextStyle( + fontFamily: AppFonts.figures, + fontSize: 25, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + height: 1, + color: palette.textPrimary, + ), + ), + const SizedBox(height: 4), + if (order.hasFixedSats) + _FixedSatsCaption( + sats: formats.decimal.format(order.amountSats!.toInt()), + style: captionStyle, + palette: palette, + ) + else + Text(l10n.marketPriceCaption, style: captionStyle), ], ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _shimmerBox(140, 26, pal.bgElevated), - _shimmerBox(48, 18, pal.bgElevated), - ], + ), + const SizedBox(width: 10), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + formats.premiumPercent(order.premium), + style: TextStyle( + fontFamily: AppFonts.figures, + fontSize: 15, + fontWeight: FontWeight.w700, + color: _premiumColor, + ), + ), + const SizedBox(height: 3), + Text( + l10n.orderCardPremiumCaption, + style: TextStyle(fontSize: 10, color: palette.textFaint), + ), + ], + ), + ], + ); + } +} + +/// "Fixed amount · for 4,000 sats", with the figure set in the figures face. +/// +/// One sentence with a placeholder rather than a prefix glued to the figure, +/// so a locale can put the figure wherever its word order needs; the styled +/// span is found by splitting around it. A translation that drops the +/// placeholder still reads, just unstyled. +class _FixedSatsCaption extends StatelessWidget { + const _FixedSatsCaption({ + required this.sats, + required this.style, + required this.palette, + }); + + /// The grouped sats figure, without its unit. + final String sats; + final TextStyle style; + final OrderBookPalette palette; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final figure = l10n.satsAmount(sats); + final caption = l10n.orderFixedAmount(figure); + final at = caption.indexOf(figure); + if (at < 0) return Text(caption, style: style); + + return Text.rich( + TextSpan( + children: [ + TextSpan(text: caption.substring(0, at)), + TextSpan( + text: figure, + style: TextStyle( + fontFamily: AppFonts.figures, + fontWeight: FontWeight.w600, + color: palette.limeInk, + ), ), - const SizedBox(height: 4), - _shimmerBox(80, 12, pal.bgElevated), - const SizedBox(height: 10), - _shimmerBox(double.infinity, 36, pal.bgElevated), - const SizedBox(height: 8), - _shimmerBox(double.infinity, 14, pal.bgElevated), + TextSpan(text: caption.substring(at + figure.length)), ], ), + style: style, + ); + } +} + +// ── Row 3: payment methods ──────────────────────────────────────────────────── + +class _PaymentMethodsRow extends StatelessWidget { + const _PaymentMethodsRow({ + required this.paymentMethod, + required this.palette, + }); + + final String paymentMethod; + final OrderBookPalette palette; + + @override + Widget build(BuildContext context) { + final methods = paymentMethod + .split(',') + .map((method) => method.trim()) + .where((method) => method.isNotEmpty) + .join(', '); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.credit_card_outlined, + size: 14, + color: palette.textTertiary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + methods, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + height: 1.45, + color: palette.textMuted, + ), + ), + ), + ], ); } +} + +// ── Row 4: reputation ───────────────────────────────────────────────────────── + +class _ReputationStrip extends StatelessWidget { + const _ReputationStrip({ + required this.order, + required this.formats, + required this.palette, + }); + + final OrderItem order; + final OrderCardFormats formats; + final OrderBookPalette palette; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final isNew = order.tradeCount == 0; + final separator = Text('|', style: TextStyle(color: palette.divider)); - Widget _shimmerBox(double width, double height, Color color) { return Container( - width: width, - height: height, + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), + color: palette.inset, + borderRadius: BorderRadius.circular(12), + ), + child: DefaultTextStyle.merge( + style: TextStyle(fontSize: 12, color: palette.textSecondary), + child: Wrap( + spacing: 10, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon( + Icons.star_rounded, + size: 14, + color: order.rating > 0 ? palette.yellow : palette.starEmpty, + ), + if (isNew) + Text( + l10n.reputationNew, + style: TextStyle( + fontWeight: FontWeight.w600, + color: palette.textNew, + ), + ) + else + Text( + formats.rating.format(order.rating), + style: TextStyle( + fontFamily: AppFonts.figures, + fontWeight: FontWeight.w600, + color: palette.textStrong, + ), + ), + separator, + if (isNew) + Text(l10n.reputationNoTrades) + else + _Stat( + value: formats.decimal.format(order.tradeCount), + label: l10n.reputationTradesLabel(order.tradeCount), + palette: palette, + ), + separator, + _Stat( + value: formats.decimal.format(order.daysActive), + label: l10n.reputationDaysLabel(order.daysActive), + palette: palette, + ), + ], + ), ), ); } } -/// Empty state when no orders match filters. -class OrderListEmpty extends StatelessWidget { - const OrderListEmpty({super.key}); +/// Figure + unit ("16 trades") in the reputation strip. +class _Stat extends StatelessWidget { + const _Stat({ + required this.value, + required this.label, + required this.palette, + }); + + final String value; + final String label; + final OrderBookPalette palette; @override Widget build(BuildContext context) { - final pal = OrderBookPalette.of(context); - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, + return Text.rich( + TextSpan( children: [ - Icon(Icons.inbox_outlined, size: 48, color: pal.textSecondary), - const SizedBox(height: AppSpacing.md), - Text( - AppLocalizations.of(context).noOrdersAvailable, - style: TextStyle(fontSize: 14, color: pal.textSecondary), + TextSpan( + text: value, + style: TextStyle( + color: palette.textBody, + fontWeight: FontWeight.w500, + ), ), + TextSpan(text: ' $label'), ], ), ); diff --git a/lib/shared/widgets/order_list_skeleton.dart b/lib/features/home/widgets/order_list_skeleton.dart similarity index 50% rename from lib/shared/widgets/order_list_skeleton.dart rename to lib/features/home/widgets/order_list_skeleton.dart index a7bf9548..5a9eb583 100644 --- a/lib/shared/widgets/order_list_skeleton.dart +++ b/lib/features/home/widgets/order_list_skeleton.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/widgets/order_book_list.dart'; import 'package:mostro/l10n/app_localizations.dart'; -/// Shimmer skeleton shown while the order book is loading. +/// Shimmer skeleton shown while the order book is loading — never a centred +/// spinner. /// -/// Renders 5 placeholder cards matching the redesigned order card's -/// [OrderBookPalette] surfaces, radius, and list spacing in both themes. +/// Five placeholder cards with the order card's radius, height and list +/// spacing, in the card's inset tone pulsing one step lighter. class OrderListSkeleton extends StatelessWidget { const OrderListSkeleton({super.key}); @@ -17,13 +19,14 @@ class OrderListSkeleton extends StatelessWidget { return Semantics( label: AppLocalizations.of(context).loadingOrders, child: Shimmer.fromColors( - // Card tone over the lighter list well, pulsing toward the inner - // panel tone — matches how a loaded card sits on [bgWell]. - baseColor: pal.bgCard, - highlightColor: pal.bgElevated, - child: ListView.builder( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 90), + baseColor: Color.alphaBlend(pal.inset, pal.bg), + highlightColor: Color.alphaBlend(pal.chipBorder, pal.bg), + period: const Duration(milliseconds: 1800), + child: ListView.separated( + padding: OrderBookList.listPadding, + physics: const NeverScrollableScrollPhysics(), itemCount: 5, + separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (_, __) => const _SkeletonCard(), ), ), @@ -34,17 +37,17 @@ class OrderListSkeleton extends StatelessWidget { class _SkeletonCard extends StatelessWidget { const _SkeletonCard(); - /// Approximate height of a rendered order card. - static const double _height = 172; + /// An order card with one line of payment methods at the default text + /// scale. + static const double _height = 190; @override Widget build(BuildContext context) { return Container( height: _height, - margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(18), ), ); } diff --git a/lib/features/home/widgets/order_sort_sheet.dart b/lib/features/home/widgets/order_sort_sheet.dart new file mode 100644 index 00000000..81579729 --- /dev/null +++ b/lib/features/home/widgets/order_sort_sheet.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +/// Shows the order-book sort picker; picking a criterion applies it and +/// closes the sheet. +Future showOrderSortSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: OrderBookPalette.of(context).surface, + builder: (_) => const _OrderSortSheet(), + ); +} + +/// Name of [sort], shared by the sheet and the filter row's caption. +String orderSortLabel(AppLocalizations l10n, OrderSort sort) => switch (sort) { + OrderSort.newest => l10n.sortNewest, + OrderSort.bestPremium => l10n.sortBestPremium, + OrderSort.bestReputation => l10n.sortBestReputation, +}; + +class _OrderSortSheet extends ConsumerWidget { + const _OrderSortSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pal = OrderBookPalette.of(context); + final l10n = AppLocalizations.of(context); + final current = ref.watch(orderSortProvider); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Text( + l10n.sortSheetTitle, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: pal.textPrimary, + ), + ), + ), + for (final sort in OrderSort.values) + _SortOption( + label: orderSortLabel(l10n, sort), + isSelected: sort == current, + palette: pal, + onTap: () { + ref.read(orderSortProvider.notifier).state = sort; + Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } +} + +class _SortOption extends StatelessWidget { + const _SortOption({ + required this.label, + required this.isSelected, + required this.palette, + required this.onTap, + }); + + final String label; + final bool isSelected; + final OrderBookPalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + selected: isSelected, + inMutuallyExclusiveGroup: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? palette.limeInk : palette.textBody, + ), + ), + ), + if (isSelected) + Icon(Icons.check_rounded, size: 20, color: palette.limeText), + ], + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7dee0cc8..54cb1f78 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -51,10 +51,9 @@ "walkthroughSlideSixTitle": "Findest du nicht, was du brauchst?", "walkthroughSlideSixBody": "Du kannst auch dein eigenes Angebot erstellen und warten, bis jemand es annimmt. Lege den Betrag und die bevorzugte Zahlungsmethode fest — Mostro erledigt den Rest.", - "tabBuyBtc": "BTC KAUFEN", - "tabSellBtc": "BTC VERKAUFEN", - "filterButtonLabel": "FILTERN", - "offersCount": "{count, plural, =1{1 Angebot} other{{count} Angebote}}", + "tabBuyBtc": "BTC kaufen", + "tabSellBtc": "BTC verkaufen", + "filterButtonLabel": "Filtern", "noOrdersAvailable": "Keine Bestellungen verfügbar", "justNow": "Gerade eben", "minutesAgo": "Vor {m}m", @@ -674,16 +673,29 @@ "shareButton": "Teilen", "sendSatsToAddress": "Sende {sats} Sats an:", "changeButton": "Ändern", - "buyLabel": "Kaufen", - "sellLabel": "Verkaufen", "unableToOpenNotification": "Benachrichtigungsdetails können nicht geöffnet werden.", - "reasonBestPremium": "⚡ Beste Prämie", - "reasonMostReputable": "⭐ Am angesehensten", - "reasonJustPublished": "🆕 Gerade veröffentlicht", + "reasonBestPremium": "Beste Prämie", + "reasonMostReputable": "Am angesehensten", "marketPriceCaption": "Marktpreis", "reputationTradesLabel": "{count, plural, =1{Trade} other{Trades}}", "reputationDaysLabel": "{count, plural, =1{Tag} other{Tage}}", - "sortNewest": "Sortierung: neueste zuerst ▾", + "sortNewest": "Neueste", + "ordersCount": "{count, plural, =1{1 Order} other{{count} Orders}}", + "sortBestPremium": "Beste Prämie", + "sortBestReputation": "Beste Reputation", + "sortSheetTitle": "Sortieren nach", + "orderCardPremiumCaption": "Prämie", + "orderFixedAmount": "Fester Betrag · für {sats}", + "satsAmount": "{amount} sats", + "reputationNew": "Neu", + "reputationNoTrades": "keine Trades", + "bottomNavBook": "Orderbuch", + "bottomNavTrades": "Trades", + "fabDismissHint": "Zum Schließen außerhalb tippen", + "addOrderFabLabel": "Order erstellen", + "ordersEmptyHint": "Neue Orders erscheinen hier, sobald sie veröffentlicht werden.", + "ordersEmptyFilteredHint": "Keine Orders entsprechen Ihren Filtern.", + "clearFiltersButton": "Filter entfernen", "hideEarlierEvents": "Frühere Ereignisse ausblenden", "viewEarlierEvents": "{count, plural, =1{1 früheres Ereignis anzeigen} other{{count} frühere Ereignisse anzeigen}}", "goToTrade": "Zum Trade", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a91d3f26..c3862a86 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -105,17 +105,12 @@ "walkthroughSlideSixBody": "You can also create your own offer and wait for someone to take it. Set the amount and preferred payment method — Mostro handles the rest.", "@walkthroughSlideSixBody": {"description": "Body text for walkthrough slide 6"}, - "tabBuyBtc": "BUY BTC", + "tabBuyBtc": "Buy BTC", "@tabBuyBtc": {"description": "Tab label for the buy Bitcoin order book"}, - "tabSellBtc": "SELL BTC", + "tabSellBtc": "Sell BTC", "@tabSellBtc": {"description": "Tab label for the sell Bitcoin order book"}, - "filterButtonLabel": "FILTER", + "filterButtonLabel": "Filter", "@filterButtonLabel": {"description": "Button label to open order book filter options"}, - "offersCount": "{count, plural, =1{1 offer} other{{count} offers}}", - "@offersCount": { - "description": "Number of offers shown in the order book", - "placeholders": {"count": {"type": "int"}} - }, "noOrdersAvailable": "No orders available", "@noOrdersAvailable": {"description": "Empty state message when the order book has no orders"}, "justNow": "Just now", @@ -1484,18 +1479,12 @@ }, "changeButton": "Change", "@changeButton": {"description": "Button that returns to edit the Lightning address"}, - "buyLabel": "Buy", - "@buyLabel": {"description": "Buy action in the add-order button"}, - "sellLabel": "Sell", - "@sellLabel": {"description": "Sell action in the add-order button"}, "unableToOpenNotification": "Unable to open notification details.", "@unableToOpenNotification": {"description": "Snackbar shown when a notification has no linked detail to open"}, - "reasonBestPremium": "⚡ Best premium", + "reasonBestPremium": "Best premium", "@reasonBestPremium": {"description": "Order-book reason badge: best premium"}, - "reasonMostReputable": "⭐ Most reputable", + "reasonMostReputable": "Most reputable", "@reasonMostReputable": {"description": "Order-book reason badge: most reputable"}, - "reasonJustPublished": "🆕 Just published", - "@reasonJustPublished": {"description": "Order-book reason badge: just published"}, "marketPriceCaption": "Market price", "@marketPriceCaption": {"description": "Caption under an order-book card amount"}, "reputationTradesLabel": "{count, plural, =1{trade} other{trades}}", @@ -1508,8 +1497,40 @@ "description": "Unit word after the active-days count in the order-book reputation row", "placeholders": {"count": {"type": "int"}} }, - "sortNewest": "Sort: newest first ▾", + "sortNewest": "Newest", "@sortNewest": {"description": "Sort caption on the order-book filter row; must describe the ordering actually applied (newest first)"}, + "ordersCount": "{count, plural, =1{1 order} other{{count} orders}}", + "@ordersCount": {"description": "Number of orders listed on the order-book filter row", "placeholders": {"count": {"type": "int"}}}, + "sortBestPremium": "Best premium", + "@sortBestPremium": {"description": "Order-book sort option: the premium that favours the taker most first (lowest when buying BTC, highest when selling)"}, + "sortBestReputation": "Best reputation", + "@sortBestReputation": {"description": "Order-book sort option: highest maker rating first, then most trades"}, + "sortSheetTitle": "Sort by", + "@sortSheetTitle": {"description": "Title of the order-book sort picker"}, + "orderCardPremiumCaption": "premium", + "@orderCardPremiumCaption": {"description": "Small caption under the premium percentage on an order-book card"}, + "orderFixedAmount": "Fixed amount · for {sats}", + "@orderFixedAmount": {"description": "Caption under a fixed-sats order amount. {sats} is the already formatted figure with its unit (satsAmount); place it wherever the language needs it", "placeholders": {"sats": {"type": "String", "example": "4,000 sats"}}}, + "satsAmount": "{amount} sats", + "@satsAmount": {"description": "A sats figure; amount is already grouped for the locale", "placeholders": {"amount": {"type": "String", "example": "4,000"}}}, + "reputationNew": "New", + "@reputationNew": {"description": "Replaces the rating on an order-book card when the maker has no trades yet"}, + "reputationNoTrades": "no trades", + "@reputationNoTrades": {"description": "Replaces the trade count on an order-book card when the maker has no trades yet"}, + "bottomNavBook": "Book", + "@bottomNavBook": {"description": "Bottom navigation label for the order book tab (short)"}, + "bottomNavTrades": "Trades", + "@bottomNavTrades": {"description": "Bottom navigation label for the my-trades tab (short)"}, + "fabDismissHint": "Tap outside to close", + "@fabDismissHint": {"description": "Hint under the Buy/Sell buttons of the open create-order button"}, + "addOrderFabLabel": "Create order", + "@addOrderFabLabel": {"description": "Accessibility label of the round create-order button"}, + "ordersEmptyHint": "New orders appear here as soon as they are published.", + "@ordersEmptyHint": {"description": "Order-book empty state line when no filter is active"}, + "ordersEmptyFilteredHint": "No orders match your filters.", + "@ordersEmptyFilteredHint": {"description": "Order-book empty state line when filters hide every order"}, + "clearFiltersButton": "Clear filters", + "@clearFiltersButton": {"description": "Order-book empty state action that resets every filter"}, "hideEarlierEvents": "Hide earlier events", "@hideEarlierEvents": {"description": "Notification group action to collapse earlier events"}, "viewEarlierEvents": "{count, plural, =1{View 1 earlier event} other{View {count} earlier events}}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5bfa4a82..edd3ae47 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -51,10 +51,9 @@ "walkthroughSlideSixTitle": "¿No encuentras lo que necesitas?", "walkthroughSlideSixBody": "También puedes crear tu propia oferta y esperar a que alguien la tome. Establece el monto y el método de pago preferido — Mostro se encarga del resto.", - "tabBuyBtc": "COMPRAR BTC", - "tabSellBtc": "VENDER BTC", - "filterButtonLabel": "FILTRAR", - "offersCount": "{count, plural, =1{1 oferta} other{{count} ofertas}}", + "tabBuyBtc": "Comprar BTC", + "tabSellBtc": "Vender BTC", + "filterButtonLabel": "Filtrar", "noOrdersAvailable": "No hay órdenes disponibles", "justNow": "Ahora mismo", "minutesAgo": "Hace {m}m", @@ -674,16 +673,29 @@ "shareButton": "Compartir", "sendSatsToAddress": "Envía {sats} sats a:", "changeButton": "Cambiar", - "buyLabel": "Comprar", - "sellLabel": "Vender", "unableToOpenNotification": "No se pueden abrir los detalles de la notificación.", - "reasonBestPremium": "⚡ Mejor prima", - "reasonMostReputable": "⭐ Más reputado", - "reasonJustPublished": "🆕 Recién publicada", + "reasonBestPremium": "Mejor prima", + "reasonMostReputable": "Más reputado", "marketPriceCaption": "Precio de mercado", "reputationTradesLabel": "{count, plural, =1{operación} other{operaciones}}", "reputationDaysLabel": "{count, plural, =1{día} other{días}}", - "sortNewest": "Orden: más recientes ▾", + "sortNewest": "Más recientes", + "ordersCount": "{count, plural, =1{1 orden} other{{count} órdenes}}", + "sortBestPremium": "Mejor prima", + "sortBestReputation": "Mejor reputación", + "sortSheetTitle": "Ordenar por", + "orderCardPremiumCaption": "prima", + "orderFixedAmount": "Monto fijo · por {sats}", + "satsAmount": "{amount} sats", + "reputationNew": "Nuevo", + "reputationNoTrades": "sin operaciones", + "bottomNavBook": "Libro", + "bottomNavTrades": "Operaciones", + "fabDismissHint": "Toca fuera para cerrar", + "addOrderFabLabel": "Crear orden", + "ordersEmptyHint": "Las órdenes nuevas aparecen aquí en cuanto se publican.", + "ordersEmptyFilteredHint": "Ninguna orden coincide con tus filtros.", + "clearFiltersButton": "Quitar filtros", "hideEarlierEvents": "Ocultar eventos anteriores", "viewEarlierEvents": "{count, plural, =1{Ver 1 evento anterior} other{Ver {count} eventos anteriores}}", "goToTrade": "Ir a la operación", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b1195fbf..93667259 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -51,10 +51,9 @@ "walkthroughSlideSixTitle": "Vous ne trouvez pas ce qu'il vous faut ?", "walkthroughSlideSixBody": "Vous pouvez également créer votre propre offre et attendre que quelqu'un la prenne. Définissez le montant et la méthode de paiement souhaitée — Mostro s'occupe du reste.", - "tabBuyBtc": "ACHETER BTC", - "tabSellBtc": "VENDRE BTC", - "filterButtonLabel": "FILTRER", - "offersCount": "{count, plural, =1{1 offre} other{{count} offres}}", + "tabBuyBtc": "Acheter BTC", + "tabSellBtc": "Vendre BTC", + "filterButtonLabel": "Filtrer", "noOrdersAvailable": "Aucun ordre disponible", "justNow": "À l'instant", "minutesAgo": "Il y a {m}m", @@ -674,16 +673,29 @@ "shareButton": "Partager", "sendSatsToAddress": "Envoyez {sats} sats à :", "changeButton": "Modifier", - "buyLabel": "Acheter", - "sellLabel": "Vendre", "unableToOpenNotification": "Impossible d'ouvrir les détails de la notification.", - "reasonBestPremium": "⚡ Meilleure prime", - "reasonMostReputable": "⭐ Plus réputé", - "reasonJustPublished": "🆕 Vient d'être publié", + "reasonBestPremium": "Meilleure prime", + "reasonMostReputable": "Plus réputé", "marketPriceCaption": "Prix du marché", "reputationTradesLabel": "{count, plural, =1{transaction} other{transactions}}", "reputationDaysLabel": "{count, plural, =1{jour} other{jours}}", - "sortNewest": "Tri : plus récentes ▾", + "sortNewest": "Plus récentes", + "ordersCount": "{count, plural, =1{1 ordre} other{{count} ordres}}", + "sortBestPremium": "Meilleure prime", + "sortBestReputation": "Meilleure réputation", + "sortSheetTitle": "Trier par", + "orderCardPremiumCaption": "prime", + "orderFixedAmount": "Montant fixe · pour {sats}", + "satsAmount": "{amount} sats", + "reputationNew": "Nouveau", + "reputationNoTrades": "aucune transaction", + "bottomNavBook": "Carnet", + "bottomNavTrades": "Transactions", + "fabDismissHint": "Touchez à l’extérieur pour fermer", + "addOrderFabLabel": "Créer un ordre", + "ordersEmptyHint": "Les nouveaux ordres apparaissent ici dès leur publication.", + "ordersEmptyFilteredHint": "Aucun ordre ne correspond à vos filtres.", + "clearFiltersButton": "Effacer les filtres", "hideEarlierEvents": "Masquer les événements précédents", "viewEarlierEvents": "{count, plural, =1{Voir 1 événement précédent} other{Voir {count} événements précédents}}", "goToTrade": "Aller à la transaction", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 5df6b6ac..d053aa34 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -51,10 +51,9 @@ "walkthroughSlideSixTitle": "Non trovi quello che cerchi?", "walkthroughSlideSixBody": "Puoi anche creare la tua offerta e aspettare che qualcuno la accetti. Imposta l'importo e il metodo di pagamento preferito — Mostro pensa al resto.", - "tabBuyBtc": "COMPRA BTC", - "tabSellBtc": "VENDI BTC", - "filterButtonLabel": "FILTRA", - "offersCount": "{count, plural, =1{1 offerta} other{{count} offerte}}", + "tabBuyBtc": "Compra BTC", + "tabSellBtc": "Vendi BTC", + "filterButtonLabel": "Filtra", "noOrdersAvailable": "Nessun ordine disponibile", "justNow": "Proprio ora", "minutesAgo": "{m}m fa", @@ -674,16 +673,29 @@ "shareButton": "Condividi", "sendSatsToAddress": "Invia {sats} sats a:", "changeButton": "Modifica", - "buyLabel": "Compra", - "sellLabel": "Vendi", "unableToOpenNotification": "Impossibile aprire i dettagli della notifica.", - "reasonBestPremium": "⚡ Miglior premio", - "reasonMostReputable": "⭐ Più affidabile", - "reasonJustPublished": "🆕 Appena pubblicata", + "reasonBestPremium": "Miglior premio", + "reasonMostReputable": "Più affidabile", "marketPriceCaption": "Prezzo di mercato", "reputationTradesLabel": "{count, plural, =1{operazione} other{operazioni}}", "reputationDaysLabel": "{count, plural, =1{giorno} other{giorni}}", - "sortNewest": "Ordina: più recenti ▾", + "sortNewest": "Più recenti", + "ordersCount": "{count, plural, =1{1 ordine} other{{count} ordini}}", + "sortBestPremium": "Miglior premio", + "sortBestReputation": "Miglior reputazione", + "sortSheetTitle": "Ordina per", + "orderCardPremiumCaption": "premio", + "orderFixedAmount": "Importo fisso · per {sats}", + "satsAmount": "{amount} sats", + "reputationNew": "Nuovo", + "reputationNoTrades": "nessuna operazione", + "bottomNavBook": "Book", + "bottomNavTrades": "Operazioni", + "fabDismissHint": "Tocca fuori per chiudere", + "addOrderFabLabel": "Crea ordine", + "ordersEmptyHint": "I nuovi ordini appaiono qui appena vengono pubblicati.", + "ordersEmptyFilteredHint": "Nessun ordine corrisponde ai tuoi filtri.", + "clearFiltersButton": "Rimuovi filtri", "hideEarlierEvents": "Nascondi eventi precedenti", "viewEarlierEvents": "{count, plural, =1{Vedi 1 evento precedente} other{Vedi {count} eventi precedenti}}", "goToTrade": "Vai all'operazione", diff --git a/lib/shared/widgets/add_order_button.dart b/lib/shared/widgets/add_order_button.dart index 5d04a889..e312aee3 100644 --- a/lib/shared/widgets/add_order_button.dart +++ b/lib/shared/widgets/add_order_button.dart @@ -7,10 +7,33 @@ import 'package:mostro/core/automation/automation_id.dart'; import 'package:mostro/core/automation/automation_ids.dart'; import 'package:mostro/l10n/app_localizations.dart'; -/// Expandable FAB for creating orders. +const double _buttonSize = 56; + +/// One clock for opening and closing: the ✕ turns over all of it, the scrim +/// fades in over the first 150 ms and the buttons enter over the first 180 ms +/// (handoff 4d). +const Duration _openDuration = Duration(milliseconds: 200); + +const double _menuSideInset = 18; + +/// Gap between the Buy/Sell stack and the top of the round button. +const double _menuGap = 18; + +/// How far the buttons rise while they fade in. +const double _menuRise = 8; + +/// Create-order button (order-book handoff, variant 4d). +/// +/// Closed: a 56-dp lime circle with a "+". Open: a scrim over the whole +/// screen — bottom bar included — with Buy and Sell as equal full-width +/// buttons above the button, which turns into a grey ✕ in the same place. +/// Tapping the scrim, the ✕ or system back closes it. /// -/// Collapsed: circular 56dp green "+" button. -/// Expanded: gray "×", dark overlay, two stacked buttons (Buy + Sell). +/// The open state is its own [OverlayEntry] above the route. The Scaffold +/// gives the button only its own slot, so a scrim drawn in place could never +/// cover the bottom bar; and only an entry painted after the route can block +/// the route's semantics — an [OverlayPortal] child is attached, for +/// semantics, where the button sits, before the bottom bar. class AddOrderButton extends StatefulWidget { const AddOrderButton({super.key}); @@ -20,171 +43,394 @@ class AddOrderButton extends StatefulWidget { class _AddOrderButtonState extends State with SingleTickerProviderStateMixin { - bool _expanded = false; + final _buttonKey = GlobalKey(); late final AnimationController _controller; - late final Animation _expandAnimation; + late final CurvedAnimation _scrim; + late final CurvedAnimation _entrance; + + /// The open menu, inserted into the navigator's overlay above the route. + OverlayEntry? _menu; + + bool get _isOpen => _menu != null; + + /// Screen size the open menu was placed for. + Size? _menuScreenSize; + + /// Keyboard focus boundary of the open menu, and what held focus before it + /// opened so closing can hand focus back. + final _menuFocus = FocusScopeNode(debugLabel: 'Create-order menu'); + FocusNode? _focusBeforeOpen; @override void initState() { super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), + // Created eagerly: a lazy controller on a button that was never opened + // would first be built in dispose(), and a deactivated element cannot + // create a ticker. + _controller = AnimationController(vsync: this, duration: _openDuration); + _scrim = CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.75), ); - _expandAnimation = CurvedAnimation( + _entrance = CurvedAnimation( parent: _controller, - curve: Curves.easeOut, + curve: const Interval(0, 0.9, curve: Curves.easeOut), ); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The menu is placed from where the button sat when it opened. Rotating + // or resizing moves the button, so close rather than leave the ✕ and the + // Buy/Sell buttons behind. After the frame: resetting the controller here + // would rebuild the menu's animated widgets in the middle of a build. + final size = MediaQuery.sizeOf(context); + if (_isOpen && size != _menuScreenSize) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isOpen) return; + setState(() { + _controller.value = 0; + _discardMenu(); + }); + }); + } + } + @override void dispose() { + _discardMenu(restoreFocus: false); + _menuFocus.dispose(); + _scrim.dispose(); + _entrance.dispose(); _controller.dispose(); super.dispose(); } - void _toggle() { - setState(() => _expanded = !_expanded); - if (_expanded) { - _controller.forward(); - } else { - _controller.reverse(); - } + void _open() { + final button = _buttonKey.currentContext?.findRenderObject() as RenderBox?; + final overlay = Overlay.of(context); + final overlayBox = overlay.context.findRenderObject() as RenderBox?; + if (_isOpen || button == null || overlayBox == null) return; + + // Where the closed button sits in the overlay, so the ✕ lands exactly on + // top of it. + final buttonRect = + button.localToGlobal(Offset.zero, ancestor: overlayBox) & button.size; + final menu = OverlayEntry( + builder: + (context) => _OpenMenu( + palette: OrderBookPalette.of(context), + scrim: _scrim, + entrance: _entrance, + rotation: _controller, + buttonRect: buttonRect, + focusScope: _menuFocus, + onDismiss: _close, + onCreate: _create, + ), + ); + overlay.insert(menu); + _menuScreenSize = MediaQuery.sizeOf(context); + _focusBeforeOpen = FocusManager.instance.primaryFocus; + setState(() => _menu = menu); + _controller.forward(); + // Autofocus would not take: the route's scope already holds focus. Move it + // into the menu once its scope is in the tree. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isOpen) _menuFocus.requestFocus(); + }); } - void _collapse() { - if (_expanded) _toggle(); + Future _close() async { + if (!_isOpen) return; + await _controller.reverse(); + if (mounted) setState(_discardMenu); + } + + void _create(String type) { + _controller.value = 0; + setState(_discardMenu); + context.push('${AppRoute.addOrder}?type=$type'); + } + + void _discardMenu({bool restoreFocus = true}) { + if (_menu == null) return; + _menu! + ..remove() + ..dispose(); + _menu = null; + + final previous = _focusBeforeOpen; + _focusBeforeOpen = null; + if (restoreFocus && previous != null && previous.context != null) { + previous.requestFocus(); + } } @override Widget build(BuildContext context) { - final colors = Theme.of(context).extension(); - final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); - final sellColor = colors?.sellColor ?? const Color(0xFFFF8A8A); - - return Stack( - alignment: Alignment.bottomRight, - children: [ - // Dark overlay - if (_expanded) - Positioned.fill( - child: GestureDetector( - onTap: _collapse, - child: Container( - color: Colors.black.withValues(alpha: 0.3), - ), - ), - ), + return PopScope( + canPop: !_isOpen, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _close(); + }, + child: Padding( + // The Scaffold keeps floating buttons 16 from the edge; the mock sets + // 18. + padding: const EdgeInsets.only(right: 2), + child: _RoundButton( + key: _buttonKey, + palette: OrderBookPalette.of(context), + onPressed: _open, + ).withAutomationId(AutomationIds.orderAddFab), + ), + ); + } +} - // Sub-buttons + main FAB column - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Buy button - FadeTransition( - opacity: _expandAnimation, - child: ScaleTransition( - scale: _expandAnimation, - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: _SubButton( - label: AppLocalizations.of(context).buyLabel, - icon: Icons.arrow_downward, - color: green, - onTap: () { - _collapse(); - context.push('${AppRoute.addOrder}?type=buy'); - }, - ).withAutomationId(AutomationIds.orderAddBuy), - ), - ), - ), +class _OpenMenu extends StatelessWidget { + const _OpenMenu({ + required this.palette, + required this.scrim, + required this.entrance, + required this.rotation, + required this.buttonRect, + required this.focusScope, + required this.onDismiss, + required this.onCreate, + }); - // Sell button - FadeTransition( - opacity: _expandAnimation, - child: ScaleTransition( - scale: _expandAnimation, - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: _SubButton( - label: AppLocalizations.of(context).sellLabel, - icon: Icons.arrow_upward, - color: sellColor, - onTap: () { - _collapse(); - context.push('${AppRoute.addOrder}?type=sell'); - }, - ).withAutomationId(AutomationIds.orderAddSell), - ), - ), - ), + final OrderBookPalette palette; + final Animation scrim; + final Animation entrance; + final Animation rotation; + final Rect buttonRect; + final FocusScopeNode focusScope; + final VoidCallback onDismiss; + final ValueChanged onCreate; - // Main FAB - FloatingActionButton( - heroTag: 'addOrderFab', - onPressed: _toggle, - backgroundColor: _expanded ? Colors.grey[700] : green, - child: AnimatedRotation( - turns: _expanded ? 0.125 : 0, - duration: const Duration(milliseconds: 200), - child: Icon( - _expanded ? Icons.close : Icons.add, - color: _expanded ? Colors.white : Colors.black, - ), + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + // Modal while open: from its own overlay entry, BlockSemantics drops the + // whole route — page and bottom bar — from the semantics tree, and the + // route-like scope makes a screen reader treat the open menu as the + // current screen. + final menu = BlockSemantics( + child: Semantics( + container: true, + explicitChildNodes: true, + scopesRoute: true, + child: LayoutBuilder( + builder: + (context, constraints) => Stack( + children: [ + Positioned.fill( + child: FadeTransition( + opacity: scrim, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onDismiss, + child: ColoredBox(color: palette.scrim), + ), + ), + ), + Positioned( + left: _menuSideInset, + right: _menuSideInset, + bottom: constraints.maxHeight - buttonRect.top + _menuGap, + child: AnimatedBuilder( + animation: entrance, + builder: + (context, child) => Opacity( + opacity: entrance.value, + child: Transform.translate( + offset: Offset( + 0, + _menuRise * (1 - entrance.value), + ), + child: child, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MenuButton( + label: l10n.tabBuyBtc, + icon: Icons.arrow_downward_rounded, + fill: palette.lime, + ink: palette.onLime, + shadow: palette.buyShadow, + onTap: () => onCreate('buy'), + ).withAutomationId(AutomationIds.orderAddBuy), + const SizedBox(height: 10), + _MenuButton( + label: l10n.tabSellBtc, + icon: Icons.arrow_upward_rounded, + fill: palette.sell, + ink: palette.onSell, + shadow: palette.sellShadow, + onTap: () => onCreate('sell'), + ).withAutomationId(AutomationIds.orderAddSell), + const SizedBox(height: 12), + Text( + l10n.fabDismissHint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: palette.scrimText, + ), + ), + ], + ), + ), + ), + Positioned.fromRect( + rect: buttonRect, + child: _RoundButton( + palette: palette, + rotation: rotation, + onPressed: onDismiss, + ).withAutomationId(AutomationIds.orderAddFab), + ), + ], ), - ).withAutomationId(AutomationIds.orderAddFab), - ], ), - ], + ), ); + + // A route-like focus boundary too: keyboard traversal cycles through Buy, + // Sell and Close instead of reaching controls hidden behind the scrim. + // No semantics of its own, so the blocking above is left untouched. + return FocusScope(node: focusScope, includeSemantics: false, child: menu); } } -class _SubButton extends StatelessWidget { - const _SubButton({ +/// Buy or Sell: full width, 52 tall, the same size for both — neither side +/// outranks the other. +class _MenuButton extends StatelessWidget { + const _MenuButton({ required this.label, required this.icon, - required this.color, + required this.fill, + required this.ink, + required this.shadow, required this.onTap, }); final String label; final IconData icon; - final Color color; + final Color fill; + final Color ink; + final List shadow; final VoidCallback onTap; @override Widget build(BuildContext context) { - return Material( - color: color, - borderRadius: BorderRadius.circular(AppRadius.button), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.button), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, + const radius = BorderRadius.all(Radius.circular(16)); + + return DecoratedBox( + decoration: BoxDecoration(borderRadius: radius, boxShadow: shadow), + child: Material( + color: fill, + borderRadius: radius, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 19, color: ink), + const SizedBox(width: 10), + Flexible( + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: ink, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// The round button: lime "+" when closed; grey ✕, turning into place, when +/// [rotation] is given (the open state). +class _RoundButton extends StatelessWidget { + const _RoundButton({ + super.key, + required this.palette, + required this.onPressed, + this.rotation, + }); + + final OrderBookPalette palette; + final VoidCallback onPressed; + final Animation? rotation; + + @override + Widget build(BuildContext context) { + final rotation = this.rotation; + final isOpen = rotation != null; + + return Semantics( + button: true, + label: + isOpen + ? MaterialLocalizations.of(context).closeButtonTooltip + : AppLocalizations.of(context).addOrderFabLabel, + child: SizedBox.square( + dimension: _buttonSize, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: isOpen ? null : palette.fabShadow, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 18, color: Colors.black), - const SizedBox(width: AppSpacing.sm), - Text( - label, - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 14, - ), + child: Material( + color: isOpen ? palette.fabClose : palette.lime, + shape: CircleBorder( + side: + isOpen + ? BorderSide(color: palette.fabCloseBorder) + : BorderSide.none, + ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onPressed, + child: Center( + child: + isOpen + ? RotationTransition( + turns: Tween( + begin: -0.125, + end: 0.0, + ).animate(rotation), + child: Icon( + Icons.close, + size: 22, + color: palette.fabCloseIcon, + ), + ) + : Icon(Icons.add, size: 26, color: palette.onLime), ), - ], + ), ), ), ), diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index 9ed84e95..b753caae 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -14,7 +14,12 @@ import 'package:mostro/shared/providers/nav_providers.dart'; /// Badge count for the Chat tab. Will be wired to Rust bridge. final chatNotificationCountProvider = StateProvider((_) => 0); +const double _barHeight = 68; + /// Bottom navigation bar with 3 tabs: Order Book, My Trades, Chat. +/// +/// Order-book handoff 4b: 68 tall on the navigation surface under a hairline, +/// each destination a 20-dp icon over a 10-dp label, lime when active. class BottomNavBar extends ConsumerWidget { const BottomNavBar({super.key}); @@ -26,106 +31,152 @@ class BottomNavBar extends ConsumerWidget { } final currentIndex = ref.watch(bottomNavIndexProvider); - final colors = Theme.of(context).extension(); - final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); - final disabledColor = colors?.textDisabled ?? const Color(0xFF6C757D); + final palette = OrderBookPalette.of(context); final tradesCount = ref.watch(orderBookNotificationCountProvider); final chatCount = ref.watch(chatNotificationCountProvider); final l10n = AppLocalizations.of(context); - return BottomNavigationBar( - currentIndex: currentIndex, - selectedItemColor: green, - unselectedItemColor: disabledColor, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - onTap: (index) { - ref.read(bottomNavIndexProvider.notifier).state = index; - switch (index) { - case 0: - context.go(AppRoute.home); - case 1: - context.go(AppRoute.orderBook); - case 2: - context.go(AppRoute.chatList); - default: - assert(false, 'Unexpected bottom nav index: $index'); - } - }, - // The identifier rides the item's icon: BottomNavigationBar builds the - // item's own semantics node, so this is the innermost widget the tab - // owns, and its bounds are inside the tab's tap target. - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.list_alt_outlined) - .withAutomationId(AutomationIds.navOrderBook), - activeIcon: const Icon(Icons.list_alt) - .withAutomationId(AutomationIds.navOrderBook), - label: l10n.navOrderBook, - ), - BottomNavigationBarItem( - icon: _BadgeIcon( - icon: Icons.bolt_outlined, - count: tradesCount, - color: colors?.destructiveRed ?? const Color(0xFFD84D4D), - ).withAutomationId(AutomationIds.navTrades), - activeIcon: _BadgeIcon( - icon: Icons.bolt, - count: tradesCount, - color: colors?.destructiveRed ?? const Color(0xFFD84D4D), - ).withAutomationId(AutomationIds.navTrades), - label: l10n.navMyTrades, + void select(int index) { + ref.read(bottomNavIndexProvider.notifier).state = index; + switch (index) { + case 0: + context.go(AppRoute.home); + case 1: + context.go(AppRoute.orderBook); + case 2: + context.go(AppRoute.chatList); + default: + assert(false, 'Unexpected bottom nav index: $index'); + } + } + + final destinations = [ + ( + id: AutomationIds.navOrderBook, + icon: Icons.list_alt_outlined, + activeIcon: Icons.list_alt, + label: l10n.bottomNavBook, + badge: 0, + ), + ( + id: AutomationIds.navTrades, + icon: Icons.bolt_outlined, + activeIcon: Icons.bolt, + label: l10n.bottomNavTrades, + badge: tradesCount, + ), + ( + id: AutomationIds.navChat, + icon: Icons.chat_bubble_outline, + activeIcon: Icons.chat_bubble, + label: l10n.navChat, + badge: chatCount, + ), + ]; + + // Material (not a coloured box) so the items' ink splashes paint on it. + return Material( + color: palette.surfaceNav, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.navBorder)), ), - BottomNavigationBarItem( - icon: _BadgeIcon( - icon: Icons.chat_bubble_outline, - count: chatCount, - color: colors?.destructiveRed ?? const Color(0xFFD84D4D), - ).withAutomationId(AutomationIds.navChat), - activeIcon: _BadgeIcon( - icon: Icons.chat_bubble, - count: chatCount, - color: colors?.destructiveRed ?? const Color(0xFFD84D4D), - ).withAutomationId(AutomationIds.navChat), - label: l10n.navChat, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), + child: SizedBox( + height: _barHeight, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), + child: Row( + children: [ + for (final (index, destination) in destinations.indexed) + Expanded( + // The identifier names the whole destination, so the + // tap action and selected state travel with it. + child: _NavItem( + icon: + index == currentIndex + ? destination.activeIcon + : destination.icon, + label: destination.label, + badgeCount: destination.badge, + isActive: index == currentIndex, + palette: palette, + onTap: () => select(index), + ).withAutomationId(destination.id), + ), + ], + ), + ), + ), ), - ], + ), ); } } -/// Icon with an optional red dot badge. -class _BadgeIcon extends StatelessWidget { - const _BadgeIcon({ +class _NavItem extends StatelessWidget { + const _NavItem({ required this.icon, - required this.count, - required this.color, + required this.label, + required this.badgeCount, + required this.isActive, + required this.palette, + required this.onTap, }); final IconData icon; - final int count; - final Color color; + final String label; + final int badgeCount; + final bool isActive; + final OrderBookPalette palette; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - Icon(icon), - if (count > 0) - Positioned( - top: -2, - right: -4, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( + final color = isActive ? palette.limeText : palette.textTertiary; + + return Semantics( + selected: isActive, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, size: 20, color: color), + if (badgeCount > 0) + Positioned( + top: -2, + right: -4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: palette.notif, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + fontWeight: isActive ? FontWeight.w500 : FontWeight.w400, color: color, - shape: BoxShape.circle, ), ), - ), - ], + ], + ), + ), ); } } diff --git a/lib/shared/widgets/notification_bell.dart b/lib/shared/widgets/notification_bell.dart index 64e66c70..b6533d4d 100644 --- a/lib/shared/widgets/notification_bell.dart +++ b/lib/shared/widgets/notification_bell.dart @@ -15,8 +15,29 @@ import 'package:mostro/l10n/app_localizations.dart'; /// 2. **Gold pill badge** — backup done; shows unread notification count. /// /// A left-right shake animation plays whenever any indicator is active. +/// +/// The optional styling lets a redesigned screen match its mock (the order +/// book sets a 22-dp glyph and a ringed dot); screens that pass nothing keep +/// the original look. class NotificationBell extends ConsumerStatefulWidget { - const NotificationBell({super.key}); + const NotificationBell({ + super.key, + this.iconColor, + this.iconSize = 24, + this.dotColor, + this.dotRingColor, + }); + + /// Glyph colour; the ambient icon theme when null. + final Color? iconColor; + final double iconSize; + + /// Backup-reminder dot colour; the theme's destructive red when null. + final Color? dotColor; + + /// When set, the dot is drawn 7 dp with a 1.5-dp ring of this colour — the + /// page background — so it reads as cut out of the glyph. + final Color? dotRingColor; @override ConsumerState createState() => _NotificationBellState(); @@ -94,13 +115,20 @@ class _NotificationBellState extends ConsumerState child: Stack( clipBehavior: Clip.none, children: [ - const Icon(Icons.notifications_outlined, size: 24), + Icon( + Icons.notifications_outlined, + size: widget.iconSize, + color: widget.iconColor, + ), if (isActive) Positioned( top: -2, right: -2, child: backupActive - ? const _RedDot() + ? _RedDot( + color: widget.dotColor, + ringColor: widget.dotRingColor, + ) : _CountBadge(count: unreadCount), ), ], @@ -112,17 +140,24 @@ class _NotificationBellState extends ConsumerState } class _RedDot extends StatelessWidget { - const _RedDot(); + const _RedDot({this.color, this.ringColor}); + + final Color? color; + final Color? ringColor; @override Widget build(BuildContext context) { final colors = Theme.of(context).extension(); + final ringColor = this.ringColor; + final size = ringColor == null ? 8.0 : 7 + 2 * 1.5; return Container( - width: 8, - height: 8, + width: size, + height: size, decoration: BoxDecoration( - color: colors?.destructiveRed ?? const Color(0xFFD84D4D), + color: color ?? colors?.destructiveRed ?? const Color(0xFFD84D4D), shape: BoxShape.circle, + border: + ringColor == null ? null : Border.all(color: ringColor, width: 1.5), ), ); } diff --git a/lib/shared/widgets/order_filter.dart b/lib/shared/widgets/order_filter.dart index cc04f6f6..4f274674 100644 --- a/lib/shared/widgets/order_filter.dart +++ b/lib/shared/widgets/order_filter.dart @@ -67,14 +67,7 @@ class _OrderFilterDialog extends ConsumerWidget { Text(l10n.filtersDialogTitle, style: theme.textTheme.headlineSmall), TextButton( - onPressed: () { - ref.read(currencyFilterProvider.notifier).state = []; - ref.read(paymentMethodFilterProvider.notifier).state = []; - ref.read(ratingFilterProvider.notifier).state = - defaultRatingRange; - ref.read(premiumRangeFilterProvider.notifier).state = - defaultPremiumRange; - }, + onPressed: () => clearOrderFilters(ref), child: Text(l10n.resetButton, style: TextStyle(color: green)), ), ], diff --git a/pubspec.yaml b/pubspec.yaml index 06a63143..7a6ce366 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,6 +79,9 @@ dependencies: # Required by flutter_rust_bridge generated code freezed_annotation: ^2.4.4 + # Masonry grid for the order book on tablet/desktop: cards keep their + # natural height (no fixed tile ratio) and keyed rows still move on re-sort + flutter_staggered_grid_view: ^0.7.0 dependency_overrides: # permission_handler_html 0.1.4+0 (published 2026-07-31) fails to compile @@ -105,6 +108,32 @@ flutter: - assets/data/fiat.json - assets/data/payment_methods.json - assets/images/ + - assets/fonts/outfit/OFL.txt + - assets/fonts/manrope/OFL.txt + + # Brand typography (drawer and order-book handoffs), bundled so the app never + # fetches fonts at runtime. Outfit is the UI face; Manrope sets the figures + # (amounts, premiums, sats), whose tabular digits line up across cards. Both + # are OFL-1.1: the licences ship as assets and are registered with the + # LicenseRegistry at startup. + fonts: + - family: Outfit + fonts: + - asset: assets/fonts/outfit/Outfit-Regular.ttf + - asset: assets/fonts/outfit/Outfit-Medium.ttf + weight: 500 + - asset: assets/fonts/outfit/Outfit-SemiBold.ttf + weight: 600 + - asset: assets/fonts/outfit/Outfit-Bold.ttf + weight: 700 + - family: Manrope + fonts: + - asset: assets/fonts/manrope/Manrope-Medium.ttf + weight: 500 + - asset: assets/fonts/manrope/Manrope-SemiBold.ttf + weight: 600 + - asset: assets/fonts/manrope/Manrope-Bold.ttf + weight: 700 # Launcher icons. Mirrors the v1 app's configuration (MostroP2P/mobile) so both # apps ship the same artwork. Regenerate with: @@ -117,7 +146,7 @@ flutter_launcher_icons: # transparent square: iOS forbids an alpha channel in app icons, and # flattening it here would leave white corners under iOS's rounded mask. # `launcher-icon-ios.png` is the same art pre-composited onto the brand - # background (app_theme.dart `bgWell`), which also avoids a generator bug — + # background (#1D212C, the previous order-book list well), which also avoids a generator bug — # flutter_launcher_icons 0.14.4 turns near-transparent pixels into saturated # green, drawing a 1px ring around the disc. image_path_ios: "assets/images/launcher-icon-ios.png" diff --git a/test/core/order_book_palette_contrast_test.dart b/test/core/order_book_palette_contrast_test.dart index 8acb4b77..26ae7796 100644 --- a/test/core/order_book_palette_contrast_test.dart +++ b/test/core/order_book_palette_contrast_test.dart @@ -23,21 +23,18 @@ double contrastRatio(Color fg, Color bg) { return (hi + 0.05) / (lo + 0.05); } -/// Composites [fg] at [alpha] over opaque [bg] — how translucent pill fills -/// actually render on screen. -Color composite(Color fg, double alpha, Color bg) { - double mix(double f, double b) => f * alpha + b * (1 - alpha); +/// Flattens a possibly-translucent [fill] over opaque [surface] — how the +/// translucent chips, tabs and scrim actually render on screen. +Color flatten(Color fill, Color surface) { + double mix(double f, double b) => f * fill.a + b * (1 - fill.a); return Color.from( alpha: 1, - red: mix(fg.r, bg.r), - green: mix(fg.g, bg.g), - blue: mix(fg.b, bg.b), + red: mix(fill.r, surface.r), + green: mix(fill.g, surface.g), + blue: mix(fill.b, surface.b), ); } -/// Flattens a possibly-translucent fill color over [surface]. -Color flatten(Color fill, Color surface) => composite(fill, fill.a, surface); - const _aa = 4.5; void _expectAA(String label, Color fg, Color bg) { @@ -49,58 +46,105 @@ void _expectAA(String label, Color fg, Color bg) { ); } -/// Locks the Order Book legibility contract: every text role must meet -/// WCAG AA (4.5:1) on the actual composited surface it renders on. +/// Locks the order-book legibility contract: every text role must meet WCAG +/// AA (4.5:1) on the actual composited surface it renders on. void main() { for (final (mode, pal) in [ ('dark', OrderBookPalette.dark), ('light', OrderBookPalette.light), ]) { group('OrderBookPalette.$mode contrast', () { - test('body text roles on their surfaces', () { - _expectAA('$mode textPrimary/bgCard', pal.textPrimary, pal.bgCard); + final tabTrack = flatten(pal.tabTrack, pal.bg); + final inset = flatten(pal.inset, pal.surface); + + test('tabs, filter row and bottom bar', () { _expectAA( - '$mode textPrimary/bgElevated', pal.textPrimary, pal.bgElevated); - _expectAA('$mode textSecondary/bgCard', pal.textSecondary, pal.bgCard); + '$mode active tab', + pal.limeInk, + flatten(pal.tabActiveFill, tabTrack), + ); + _expectAA('$mode inactive tab', pal.textSecondary, tabTrack); _expectAA( - '$mode textSecondary/bgElevated', pal.textSecondary, pal.bgElevated); - _expectAA('$mode textSecondary/bg', pal.textSecondary, pal.bg); - // Empty/error state copy renders on the list well behind the cards. - _expectAA('$mode textSecondary/bgWell', pal.textSecondary, pal.bgWell); - // Timestamps, "Market price", the sort caption, and separators. - _expectAA('$mode textTertiary/bgCard', pal.textTertiary, pal.bgCard); - _expectAA('$mode textTertiary/bg', pal.textTertiary, pal.bg); - _expectAA('$mode textTertiary/bgWell', pal.textTertiary, pal.bgWell); + '$mode filter chip', + pal.textStrong, + flatten(pal.chipFill, pal.bg), + ); + _expectAA('$mode order count', pal.textTertiary, pal.bg); + _expectAA('$mode sort caption', pal.sortLabel, pal.bg); + _expectAA('$mode active destination', pal.limeText, pal.surfaceNav); + _expectAA( + '$mode inactive destination', + pal.textTertiary, + pal.surfaceNav, + ); }); - test('reason pills on their fills', () { + test('card text on the card', () { + for (final (name, color) in [ + ('amount', pal.textPrimary), + ('amount caption', pal.textTertiary), + ('sats figure', pal.limeInk), + ('time and premium caption', pal.textFaint), + ('payment methods', pal.textMuted), + ('premium in the taker favour', pal.limeText), + ('premium up to 3 points against', pal.premiumMid), + ('premium beyond 3 points against', pal.premiumHigh), + ]) { + _expectAA('$mode $name', color, pal.surface); + } _expectAA( - '$mode green/greenDim', pal.green, flatten(pal.greenDim, pal.bgCard)); + '$mode currency chip', + pal.textStrong, + flatten(pal.currencyChipFill, pal.surface), + ); + }); + + test('chips on their fills', () { + _expectAA( + '$mode best-premium chip', + pal.limeInk, + flatten(pal.bestChipFill, pal.surface), + ); _expectAA( - '$mode gold/goldDim', pal.gold, flatten(pal.goldDim, pal.bgCard)); + '$mode most-reputable chip', + pal.yellowInk, + flatten(pal.reputableChipFill, pal.surface), + ); _expectAA( - '$mode blue/blueFill', pal.blue, flatten(pal.blueFill, pal.bgCard)); + '$mode own-order chip', + pal.textSecondary, + flatten(pal.currencyChipFill, pal.surface), + ); }); - test('premium pills on their 13% fills over the card', () { + test('reputation strip on its inset', () { for (final (name, color) in [ - ('green', pal.green), - ('amber', pal.amber), - ('red', pal.red), + ('base text', pal.textSecondary), + ('rating', pal.textStrong), + ('figures', pal.textBody), + ('"New"', pal.textNew), ]) { - _expectAA('$mode premium $name pill', color, - composite(color, 0.13, pal.bgCard)); + _expectAA('$mode reputation $name', color, inset); + } + }); + + test('create-order menu', () { + _expectAA('$mode Buy label', pal.onLime, pal.lime); + _expectAA('$mode Sell label', pal.onSell, pal.sell); + for (final (name, under) in [('page', pal.bg), ('card', pal.surface)]) { + _expectAA( + '$mode dismiss hint over the $name', + pal.scrimText, + flatten(pal.scrim, under), + ); } }); - test('active tab label on the page background', () { - _expectAA('$mode green tab/bg', pal.green, pal.bg); + test('empty and error states on the page', () { + _expectAA('$mode empty title', pal.textBody, pal.bg); + _expectAA('$mode empty line', pal.textSecondary, pal.bg); + _expectAA('$mode clear filters', pal.limeText, pal.bg); }); }); } - - test('light inactive tab label meets AA (interactive control)', () { - _expectAA('light tabInactive/bg', OrderBookPalette.light.tabInactive, - OrderBookPalette.light.bg); - }); } diff --git a/test/features/home/goldens/order_list_item_dark.png b/test/features/home/goldens/order_list_item_dark.png index 9eec54b2..c4b7bc9a 100644 Binary files a/test/features/home/goldens/order_list_item_dark.png and b/test/features/home/goldens/order_list_item_dark.png differ diff --git a/test/features/home/goldens/order_list_item_light.png b/test/features/home/goldens/order_list_item_light.png index 2dd51d8f..c14c3fb6 100644 Binary files a/test/features/home/goldens/order_list_item_light.png and b/test/features/home/goldens/order_list_item_light.png differ diff --git a/test/features/home/order_book_list_reorder_test.dart b/test/features/home/order_book_list_reorder_test.dart index 40918202..96106f7b 100644 --- a/test/features/home/order_book_list_reorder_test.dart +++ b/test/features/home/order_book_list_reorder_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mostro/core/app_theme.dart'; import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/home/providers/order_reason_provider.dart'; import 'package:mostro/features/home/widgets/order_book_list.dart'; import 'package:mostro/features/home/widgets/order_list_item.dart'; import 'package:mostro/l10n/app_localizations.dart'; @@ -67,7 +68,8 @@ void main() { expect( identical(before, after), isTrue, - reason: 'the element should have been moved to its new index, not ' + reason: + 'the element should have been moved to its new index, not ' 'deactivated and re-inflated', ); }); @@ -90,4 +92,63 @@ void main() { }); }); }); + + group('OrderBookList grid fits long cards at 200% text', () { + // Worst case for height: chips that wrap, two lines of payment methods, + // a reputation strip that wraps. + final orders = [ + for (var i = 0; i < 4; i++) + fakeOrder( + id: 'order-$i', + fiatAmount: null, + fiatAmountMin: 100000, + fiatAmountMax: 2500000, + paymentMethod: + 'SPEI, Retiro Cajero BBVA, Transferencia Banorte, ' + 'Depósito en efectivo, Mercado Pago', + rating: 4.78, + tradeCount: 1600, + daysActive: 2190, + isMine: i.isEven, + minutesAgo: i * 10, + ), + ]; + + for (final (columns, width) in [(2, 700.0), (3, 900.0)]) { + testWidgets('with $columns columns', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await withClock(Clock.fixed(kFakeNow), () async { + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: + (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold( + body: OrderBookList( + orders: orders, + currencyFlags: const {'USD': '🇺🇸'}, + reasons: const {'order-1': OrderReason.mostReputable}, + columns: columns, + onOrderTap: (_) {}, + ), + ), + ), + ); + }); + + expect(tester.takeException(), isNull); + expect(find.byType(OrderListItem), findsWidgets); + }); + } + }); } diff --git a/test/features/home/order_list_empty_test.dart b/test/features/home/order_list_empty_test.dart new file mode 100644 index 00000000..a3b943dc --- /dev/null +++ b/test/features/home/order_list_empty_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/home/widgets/order_list_empty.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +import '../../support/fake_orders.dart'; + +const _genericHint = 'New orders appear here as soon as they are published.'; +const _filteredHint = 'No orders match your filters.'; + +/// Pumps the empty state on the default Buy BTC tab, which lists sell orders. +Future _pump( + WidgetTester tester, { + required List book, + List currencies = const [], +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + currencyFilterProvider.overrideWith((ref) => currencies), + orderBookProvider.overrideWith((ref) => Stream.value(book)), + ], + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: OrderListEmpty()), + ), + ), + ); + // Let the overridden book stream deliver. + await tester.pump(); + return ProviderScope.containerOf(tester.element(find.byType(Scaffold))); +} + +void main() { + testWidgets('without filters it explains and offers nothing to clear', ( + tester, + ) async { + await _pump(tester, book: []); + + expect(find.text('No orders available'), findsOneWidget); + expect(find.text(_genericHint), findsOneWidget); + expect(find.text('Clear filters'), findsNothing); + }); + + testWidgets('when filters hide the tab orders it offers to clear them', ( + tester, + ) async { + final container = await _pump( + tester, + book: [fakeOrder(kind: 'sell', fiatCode: 'EUR')], + currencies: ['USD'], + ); + + expect(find.text(_filteredHint), findsOneWidget); + await tester.tap(find.text('Clear filters')); + await tester.pump(); + + expect(container.read(currencyFilterProvider), isEmpty); + expect(find.text('Clear filters'), findsNothing); + }); + + testWidgets('does not blame the filters when the tab is empty anyway', ( + tester, + ) async { + // Only a buy order, which the Buy BTC tab does not list. + await _pump( + tester, + book: [fakeOrder(kind: 'buy', fiatCode: 'USD')], + currencies: ['USD'], + ); + + expect(find.text(_genericHint), findsOneWidget); + expect(find.text(_filteredHint), findsNothing); + expect(find.text('Clear filters'), findsNothing); + }); +} diff --git a/test/features/home/order_list_item_golden_test.dart b/test/features/home/order_list_item_golden_test.dart index c5aaf1dc..07449bcf 100644 --- a/test/features/home/order_list_item_golden_test.dart +++ b/test/features/home/order_list_item_golden_test.dart @@ -7,9 +7,9 @@ import 'package:mostro/features/home/widgets/order_list_item.dart'; import '../../support/fake_orders.dart'; import '../../support/golden_harness.dart'; -/// Fixed-amount and range order cards stacked, keyed for a tight golden. -/// The first card is [OrderListItem.highlighted] so one golden pair covers -/// both the glow ring and the plain hairline. +/// A fixed-sats order and a market-price range order stacked, keyed for a +/// tight golden. The first card is the best-premium one, so one golden pair +/// covers both the highlight border and the plain hairline. Widget _gallery() { return Padding( key: const ValueKey('order-gallery'), @@ -26,10 +26,12 @@ Widget _gallery() { paymentMethod: 'Wire, Revolut', premium: 2.5, rating: 4.8, + tradeCount: 16, + daysActive: 219, + amountSats: BigInt.from(4000), minutesAgo: 12, ), reason: OrderReason.bestPremium, - highlighted: true, ), const SizedBox(height: 8), OrderListItem( diff --git a/test/features/home/order_list_item_test.dart b/test/features/home/order_list_item_test.dart new file mode 100644 index 00000000..ea3ecf7c --- /dev/null +++ b/test/features/home/order_list_item_test.dart @@ -0,0 +1,233 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/home/providers/order_reason_provider.dart'; +import 'package:mostro/features/home/widgets/order_list_item.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +import '../../support/fake_orders.dart'; + +const _dark = OrderBookPalette.dark; + +Future _pumpCard( + WidgetTester tester, + OrderItem order, { + OrderReason? reason, + double width = 360, + double textScale = 1, +}) async { + tester.view.physicalSize = Size(width, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await withClock(Clock.fixed(kFakeNow), () async { + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: + (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(18), + child: OrderListItem( + order: order, + reason: reason, + currencyFlags: const {'USD': '🇺🇸'}, + ), + ), + ), + ), + ); + }); +} + +Color? _colorOf(WidgetTester tester, String text) => + tester.widget(find.text(text)).style?.color; + +void main() { + group('OrderListItem premium', () { + for (final (label, kind, premium, text, color) in [ + ('buying at a discount', 'sell', -1.5, '-1.5%', _dark.limeText), + ('buying at market', 'sell', 0.0, '0.0%', _dark.limeText), + ('buying up to 3% over', 'sell', 2.0, '+2.0%', _dark.premiumMid), + ('buying more than 3% over', 'sell', 5.0, '+5.0%', _dark.premiumHigh), + ('selling above market', 'buy', 2.0, '+2.0%', _dark.limeText), + ('selling up to 3% under', 'buy', -3.0, '-3.0%', _dark.premiumMid), + ('selling more than 3% under', 'buy', -4.0, '-4.0%', _dark.premiumHigh), + ]) { + testWidgets('is coloured for the taker when $label', (tester) async { + await _pumpCard(tester, fakeOrder(kind: kind, premium: premium)); + + expect(_colorOf(tester, text), color); + expect(find.text('premium'), findsOneWidget); + }); + } + + testWidgets('rounds a near-zero premium to an unsigned 0.0%', ( + tester, + ) async { + await _pumpCard(tester, fakeOrder(premium: 0.04)); + + expect(_colorOf(tester, '0.0%'), _dark.limeText); + }); + }); + + group('OrderListItem amount', () { + testWidgets('groups both ends of a range and never truncates it', ( + tester, + ) async { + await _pumpCard( + tester, + fakeOrder(fiatAmount: null, fiatAmountMin: 832, fiatAmountMax: 5000), + ); + + final amount = tester.widget(find.text('832 – 5,000')); + expect(amount.overflow, isNot(TextOverflow.ellipsis)); + expect(amount.maxLines, isNull); + }); + + testWidgets('market-price orders say so', (tester) async { + await _pumpCard(tester, fakeOrder(amountSats: BigInt.zero)); + + expect(find.text('Market price'), findsOneWidget); + expect(find.textContaining('sats', findRichText: true), findsNothing); + }); + + testWidgets('fixed-sats orders show the sats figure instead', ( + tester, + ) async { + await _pumpCard( + tester, + fakeOrder(fiatAmount: 25, amountSats: BigInt.from(4000)), + ); + + expect( + find.text('Fixed amount · for 4,000 sats', findRichText: true), + findsOneWidget, + ); + expect(find.text('Market price'), findsNothing); + }); + }); + + group('OrderListItem rows', () { + testWidgets('joins payment methods on one line, capped at two lines', ( + tester, + ) async { + await _pumpCard( + tester, + fakeOrder(paymentMethod: 'SPEI,Retiro Cajero BBVA , Mercado Pago'), + ); + + final methods = tester.widget( + find.text('SPEI, Retiro Cajero BBVA, Mercado Pago'), + ); + expect(methods.maxLines, 2); + expect(methods.overflow, TextOverflow.ellipsis); + }); + + testWidgets('shows rating, trades and days for a maker with history', ( + tester, + ) async { + await _pumpCard( + tester, + fakeOrder(rating: 4.78, tradeCount: 16, daysActive: 219), + ); + + expect(find.text('4.78'), findsOneWidget); + expect(find.text('16 trades', findRichText: true), findsOneWidget); + expect(find.text('219 days', findRichText: true), findsOneWidget); + expect( + tester.widget(find.byIcon(Icons.star_rounded)).color, + _dark.yellow, + ); + }); + + testWidgets('marks a maker without trades as new', (tester) async { + await _pumpCard( + tester, + fakeOrder(rating: 0, tradeCount: 0, daysActive: 29), + ); + + expect(_colorOf(tester, 'New'), _dark.textNew); + expect(find.text('no trades'), findsOneWidget); + expect( + tester.widget(find.byIcon(Icons.star_rounded)).color, + _dark.starEmpty, + ); + }); + + testWidgets('shows the currency chip with its flag', (tester) async { + await _pumpCard(tester, fakeOrder(fiatCode: 'USD')); + + expect(find.text('USD'), findsOneWidget); + expect(find.text('🇺🇸'), findsOneWidget); + }); + + testWidgets('keeps the own-order chip', (tester) async { + await _pumpCard(tester, fakeOrder(kind: 'sell', isMine: true)); + + expect(find.text('YOU ARE SELLING'), findsOneWidget); + }); + }); + + group('OrderListItem highlight', () { + bool hasBorder(WidgetTester tester, Color color) => tester.any( + find.byWidgetPredicate( + (w) => + w is Material && + w.shape is RoundedRectangleBorder && + (w.shape! as RoundedRectangleBorder).side.color == color, + ), + ); + + testWidgets('best premium gets its chip and the green border', ( + tester, + ) async { + await _pumpCard(tester, fakeOrder(), reason: OrderReason.bestPremium); + + expect(find.text('BEST PREMIUM'), findsOneWidget); + expect(hasBorder(tester, _dark.borderHighlight), isTrue); + }); + + testWidgets('most reputable gets its chip but the plain border', ( + tester, + ) async { + await _pumpCard(tester, fakeOrder(), reason: OrderReason.mostReputable); + + expect(find.text('MOST REPUTABLE'), findsOneWidget); + expect(hasBorder(tester, _dark.border), isTrue); + }); + }); + + testWidgets('does not overflow on a 320-wide phone with 2x text', ( + tester, + ) async { + await _pumpCard( + tester, + fakeOrder( + fiatAmount: null, + fiatAmountMin: 100000, + fiatAmountMax: 2500000, + paymentMethod: 'SPEI, Retiro Cajero BBVA, Transferencia Banorte', + rating: 4.78, + tradeCount: 1600, + daysActive: 2190, + isMine: true, + ), + reason: OrderReason.mostReputable, + width: 320, + textScale: 2, + ); + + expect(tester.takeException(), isNull); + }); +} diff --git a/test/features/home/order_reasons_test.dart b/test/features/home/order_reasons_test.dart new file mode 100644 index 00000000..70973c7a --- /dev/null +++ b/test/features/home/order_reasons_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/home/providers/order_reason_provider.dart'; + +import '../../support/fake_orders.dart'; + +void main() { + group('computeOrderReasons', () { + test('best premium on Buy BTC goes to the cheapest sell order', () { + final reasons = computeOrderReasons([ + fakeOrder(id: 'a', kind: 'sell', premium: 3, rating: 0), + fakeOrder(id: 'b', kind: 'sell', premium: -1, rating: 0), + ]); + + expect(reasons, {'b': OrderReason.bestPremium}); + }); + + test('best premium on Sell BTC goes to the best-paying buy order', () { + final reasons = computeOrderReasons([ + fakeOrder(id: 'a', kind: 'buy', premium: 3, rating: 0), + fakeOrder(id: 'b', kind: 'buy', premium: -1, rating: 0), + ]); + + expect(reasons, {'a': OrderReason.bestPremium}); + }); + + test('nothing is "best premium" when no order beats another', () { + expect( + computeOrderReasons([fakeOrder(id: 'lone', premium: 2, rating: 0)]), + isEmpty, + ); + expect( + computeOrderReasons([ + fakeOrder(id: 'a', premium: 0, rating: 0), + fakeOrder(id: 'b', premium: 0, rating: 0), + ]), + isEmpty, + ); + }); + + test('most reputable skips the best-premium card and needs a rating', () { + final reasons = computeOrderReasons([ + fakeOrder(id: 'a', premium: -1, rating: 5, tradeCount: 10), + fakeOrder(id: 'b', premium: 2, rating: 4.8, tradeCount: 50), + fakeOrder(id: 'c', premium: 3, rating: 0), + ]); + + expect(reasons, { + 'a': OrderReason.bestPremium, + 'b': OrderReason.mostReputable, + }); + }); + + test('only the two highlight chips from the design exist', () { + expect(OrderReason.values, [ + OrderReason.bestPremium, + OrderReason.mostReputable, + ]); + }); + }); +} diff --git a/test/features/home/order_route_test.dart b/test/features/home/order_route_test.dart new file mode 100644 index 00000000..337e6442 --- /dev/null +++ b/test/features/home/order_route_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/features/home/screens/home_screen.dart'; + +import '../../support/fake_orders.dart'; + +/// The route comes from the order, not from the tab: during the Buy/Sell +/// cross-fade the outgoing list is still tappable while the tab already names +/// the other side, so reading the tab would open the wrong take flow. +void main() { + group('routeForOrder', () { + test('a sell order opens the take-sell flow', () { + expect( + routeForOrder(fakeOrder(id: 's', kind: 'sell')), + AppRoute.takeSellPath('s'), + ); + }); + + test('a buy order opens the take-buy flow', () { + expect( + routeForOrder(fakeOrder(id: 'b', kind: 'buy')), + AppRoute.takeBuyPath('b'), + ); + }); + + test('our own order opens its own screen, whatever its side', () { + for (final kind in ['sell', 'buy']) { + expect( + routeForOrder(fakeOrder(id: 'm', kind: kind, isMine: true)), + AppRoute.myOrderPath('m'), + ); + } + }); + }); +} diff --git a/test/features/home/order_sort_sheet_test.dart b/test/features/home/order_sort_sheet_test.dart new file mode 100644 index 00000000..f99f9166 --- /dev/null +++ b/test/features/home/order_sort_sheet_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; +import 'package:mostro/features/home/widgets/order_sort_sheet.dart'; +import 'package:mostro/l10n/app_localizations.dart'; + +void main() { + testWidgets('lists the three criteria and applies the one tapped', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: + (context) => TextButton( + onPressed: () => showOrderSortSheet(context), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(Scaffold)), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Sort by'), findsOneWidget); + expect(find.text('Newest'), findsOneWidget); + expect(find.text('Best premium'), findsOneWidget); + await tester.tap(find.text('Best reputation')); + await tester.pumpAndSettle(); + + expect(container.read(orderSortProvider), OrderSort.bestReputation); + expect(find.text('Sort by'), findsNothing); + }); +} diff --git a/test/features/home/order_sort_test.dart b/test/features/home/order_sort_test.dart new file mode 100644 index 00000000..93582abb --- /dev/null +++ b/test/features/home/order_sort_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro/features/home/providers/home_order_providers.dart'; + +import '../../support/fake_orders.dart'; +import '../../support/order_book_harness.dart'; + +void main() { + group('takerPremiumAdvantage', () { + test('taking a sell order (buying BTC) favours a lower premium', () { + expect(fakeOrder(kind: 'sell', premium: -2).takerPremiumAdvantage, 2); + expect(fakeOrder(kind: 'sell', premium: 3).takerPremiumAdvantage, -3); + }); + + test('taking a buy order (selling BTC) favours a higher premium', () { + expect(fakeOrder(kind: 'buy', premium: 3).takerPremiumAdvantage, 3); + expect(fakeOrder(kind: 'buy', premium: -2).takerPremiumAdvantage, -2); + }); + }); + + group('order-book sort', () { + test('defaults to newest first', () async { + final helper = await bookWith([ + fakeOrder(id: 'old', minutesAgo: 30), + fakeOrder(id: 'new', minutesAgo: 1), + ]); + + expect(helper.container.read(orderSortProvider), OrderSort.newest); + expect(helper.ids(), ['new', 'old']); + }); + + test('best premium on Buy BTC lists the cheapest sell order first', () async { + final helper = await bookWith([ + fakeOrder(id: 'plus5', kind: 'sell', premium: 5, minutesAgo: 1), + fakeOrder(id: 'minus2', kind: 'sell', premium: -2, minutesAgo: 2), + fakeOrder(id: 'zero', kind: 'sell', premium: 0, minutesAgo: 3), + ]); + helper.setTab(OrderType.buy); + + helper.container.read(orderSortProvider.notifier).state = + OrderSort.bestPremium; + + expect(helper.ids(), ['minus2', 'zero', 'plus5']); + }); + + test('best premium on Sell BTC lists the best-paying buy order first', + () async { + final helper = await bookWith([ + fakeOrder(id: 'plus5', kind: 'buy', premium: 5, minutesAgo: 1), + fakeOrder(id: 'minus2', kind: 'buy', premium: -2, minutesAgo: 2), + fakeOrder(id: 'zero', kind: 'buy', premium: 0, minutesAgo: 3), + ]); + helper.setTab(OrderType.sell); + + helper.container.read(orderSortProvider.notifier).state = + OrderSort.bestPremium; + + expect(helper.ids(), ['plus5', 'zero', 'minus2']); + }); + + test('equal premiums keep newest first', () async { + final helper = await bookWith([ + fakeOrder(id: 'older', premium: 1, minutesAgo: 10), + fakeOrder(id: 'recent', premium: 1, minutesAgo: 1), + ]); + + helper.container.read(orderSortProvider.notifier).state = + OrderSort.bestPremium; + + expect(helper.ids(), ['recent', 'older']); + }); + + test('best reputation ranks by rating, then by trade count', () async { + final helper = await bookWith([ + fakeOrder(id: 'a', rating: 4.5, tradeCount: 50, minutesAgo: 1), + fakeOrder(id: 'b', rating: 4.9, tradeCount: 3, minutesAgo: 2), + fakeOrder(id: 'c', rating: 4.9, tradeCount: 20, minutesAgo: 3), + ]); + + helper.container.read(orderSortProvider.notifier).state = + OrderSort.bestReputation; + + expect(helper.ids(), ['c', 'b', 'a']); + }); + + test('equal rating and trade count keep newest first', () async { + final helper = await bookWith([ + fakeOrder(id: 'older', rating: 4.8, tradeCount: 12, minutesAgo: 30), + fakeOrder(id: 'recent', rating: 4.8, tradeCount: 12, minutesAgo: 2), + ]); + + helper.container.read(orderSortProvider.notifier).state = + OrderSort.bestReputation; + + expect(helper.ids(), ['recent', 'older']); + }); + }); + + group('hasActiveOrderFiltersProvider', () { + test('is false by default and true once a filter narrows the list', + () async { + final helper = await bookWith([]); + expect(helper.container.read(hasActiveOrderFiltersProvider), isFalse); + + helper.container.read(premiumRangeFilterProvider.notifier).state = + (min: -2.0, max: 10.0); + + expect(helper.container.read(hasActiveOrderFiltersProvider), isTrue); + }); + }); +} diff --git a/test/shared/widgets/add_order_button_test.dart b/test/shared/widgets/add_order_button_test.dart new file mode 100644 index 00000000..4aaccceb --- /dev/null +++ b/test/shared/widgets/add_order_button_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/shared/widgets/add_order_button.dart'; + +Future _pump(WidgetTester tester) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: + (_, __) => const Scaffold( + body: Center(child: Text('BOOK')), + floatingActionButton: AddOrderButton(), + bottomNavigationBar: SizedBox( + height: 68, + child: Center(child: Text('NAV')), + ), + ), + ), + GoRoute( + path: AppRoute.addOrder, + builder: (_, state) => Text('add ${state.uri.queryParameters['type']}'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ); +} + +Finder get _scrim => find.byWidgetPredicate( + (w) => w is ColoredBox && w.color == OrderBookPalette.dark.scrim, +); + +Finder _buttonWith(String label) => + find.ancestor(of: find.text(label), matching: find.byType(InkWell)).first; + +void main() { + testWidgets('opens a full-screen scrim over the bottom bar', (tester) async { + await _pump(tester); + expect(_scrim, findsNothing); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + final screen = tester.view.physicalSize / tester.view.devicePixelRatio; + expect(tester.getSize(_scrim), screen); + expect( + tester.getRect(_scrim).contains(tester.getCenter(find.text('NAV'))), + isTrue, + ); + expect(find.text('Tap outside to close'), findsOneWidget); + }); + + testWidgets('shows Buy and Sell as equal full-width buttons', (tester) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + final buy = tester.getRect(_buttonWith('Buy BTC')); + final sell = tester.getRect(_buttonWith('Sell BTC')); + expect(buy.size, sell.size); + expect(buy.height, greaterThanOrEqualTo(52)); + expect(buy.left, 18); + expect(buy.width, tester.getSize(_scrim).width - 36); + }); + + testWidgets('tapping the scrim closes it', (tester) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.tapAt(const Offset(200, 40)); + await tester.pumpAndSettle(); + + expect(_scrim, findsNothing); + expect(find.text('Buy BTC'), findsNothing); + }); + + testWidgets('the close button in the open state collapses it', ( + tester, + ) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(_scrim, findsNothing); + }); + + testWidgets('closes when the screen changes size while open', (tester) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(1000, 700); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + expect(_scrim, findsNothing); + expect(find.text('Buy BTC'), findsNothing); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('hides what is behind the scrim from screen readers', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await _pump(tester); + // Semantics finders search the live semantics tree, where blocked nodes + // are actually gone. + expect(find.semantics.byLabel('NAV'), findsOne); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.semantics.byLabel('NAV'), findsNothing); + expect(find.semantics.byLabel(RegExp('Buy BTC')), findsOne); + semantics.dispose(); + }); + + testWidgets('keeps keyboard focus inside the open menu', (tester) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + final visited = {}; + for (var i = 0; i < 6; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + final focus = FocusManager.instance.primaryFocus!; + expect(focus.enclosingScope?.debugLabel, 'Create-order menu'); + visited.add(focus); + } + + // Twice round the three controls: Buy, Sell and Close, nothing else. + expect(visited, hasLength(3)); + }); + + for (final (label, type) in [('Buy BTC', 'buy'), ('Sell BTC', 'sell')]) { + testWidgets('$label closes and opens the create-order form', ( + tester, + ) async { + await _pump(tester); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + + expect(find.text('add $type'), findsOneWidget); + expect(_scrim, findsNothing); + }); + } +} diff --git a/test/shared/widgets/bottom_nav_bar_test.dart b/test/shared/widgets/bottom_nav_bar_test.dart new file mode 100644 index 00000000..65c2406b --- /dev/null +++ b/test/shared/widgets/bottom_nav_bar_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/trades/providers/trades_providers.dart' + show orderBookNotificationCountProvider; +import 'package:mostro/l10n/app_localizations.dart'; +import 'package:mostro/shared/widgets/bottom_nav_bar.dart'; + +Future _pump(WidgetTester tester) async { + GoRoute route(String path) => GoRoute( + path: path, + builder: + (_, __) => Scaffold( + body: Text('route $path'), + bottomNavigationBar: const BottomNavBar(), + ), + ); + final router = GoRouter( + routes: [ + route(AppRoute.home), + route(AppRoute.orderBook), + route(AppRoute.chatList), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + ProviderScope( + overrides: [ + orderBookNotificationCountProvider.overrideWith((ref) => 0), + chatNotificationCountProvider.overrideWith((ref) => 0), + ], + child: MaterialApp.router( + routerConfig: router, + theme: buildDarkTheme(), + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('shows the three short labels on a 68-tall bar', (tester) async { + await _pump(tester); + + for (final label in ['Book', 'Trades', 'Chat']) { + expect(find.text(label), findsOneWidget); + } + expect(tester.getSize(find.byType(BottomNavBar)).height, 68); + }); + + testWidgets('colours the active destination lime and the rest muted', ( + tester, + ) async { + await _pump(tester); + + Color? colorOf(String label) => + tester.widget(find.text(label)).style?.color; + expect(colorOf('Book'), OrderBookPalette.dark.limeText); + expect(colorOf('Trades'), OrderBookPalette.dark.textTertiary); + }); + + testWidgets('navigates to the tapped destination', (tester) async { + await _pump(tester); + + await tester.tap(find.text('Trades')); + await tester.pumpAndSettle(); + + expect(find.text('route ${AppRoute.orderBook}'), findsOneWidget); + }); +} diff --git a/test/support/fake_orders.dart b/test/support/fake_orders.dart index b5144a70..7e2506de 100644 --- a/test/support/fake_orders.dart +++ b/test/support/fake_orders.dart @@ -18,6 +18,9 @@ OrderItem fakeOrder({ double premium = 0, String creatorPubkey = 'pubkey-1', double rating = 5.0, + int tradeCount = 0, + int daysActive = 0, + BigInt? amountSats, OrderStatus status = OrderStatus.pending, bool isMine = false, int minutesAgo = 0, @@ -35,6 +38,9 @@ OrderItem fakeOrder({ creatorPubkey: creatorPubkey, createdAt: kFakeNow.subtract(Duration(minutes: minutesAgo)), rating: rating, + tradeCount: tradeCount, + daysActive: daysActive, + amountSats: amountSats, status: status, isMine: isMine, );