diff --git a/.gitignore b/.gitignore index f2fc72d..5e82815 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,9 @@ app.*.map.json /android/app/release /android/key.properties *.jks + +# Binários locais de ferramentas (ex.: act) +/bin/ + +# Imagens geradas quando um golden falha +**/failures/ diff --git a/README.md b/README.md index e956f2f..0fd8d9c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RealCalc

- RealCalc — Financiamento, aplicações e Selic em um só lugar + RealCalc: contas de dinheiro, sem mistério. Financiamento, aplicações e correção de valores.

@@ -13,8 +13,6 @@ Uma releitura moderna da **Calculadora do Cidadão**, o app oficial do Banco Central do Brasil para cálculos financeiros. O RealCalc parte da mesma lógica de cálculo (juros compostos, depósitos regulares, financiamento), mas repensa a experiência do zero — identidade visual própria, hierarquia clara entre ações, e uma solução pro problema que o app original nunca resolveu bem: em cada calculadora, qualquer um dos campos pode ser a incógnita, e a interface original não deixa isso óbvio. > Projeto pessoal e não-oficial. Sem qualquer vínculo com o Banco Central do Brasil — construído como estudo de produto, UX e arquitetura em Flutter. -> -> Em evolução: algumas rotas já estão previstas na navegação, mas ainda não possuem uma tela implementada. ## O problema que o projeto resolve @@ -34,15 +32,31 @@ Essa lógica vive num Cubit dedicado por calculadora, reagindo em tempo real a c - Simulação de depósitos regulares com capitalização composta. - Fluxo de financiamento com os 4 estados de campo descritos acima. - Card de métrica com a taxa Selic atual, variação desde o último ajuste do Copom e uma sparkline de tendência — com estados de carregamento e offline (mostra o último valor salvo, ou um estado de "tentar novamente"). -- Interface com design system próprio: paleta de cores, tipografia e componentes centralizados via `ThemeExtension`, sem cor ou estilo hardcoded espalhado pelos widgets. +- Interface com design system próprio: paleta de cores (`AppColors`, uma `ThemeExtension` com os dois temas), tipografia e ícones centralizados, sem cor ou estilo hardcoded espalhado pelos widgets. - Testes unitários, de widgets e de módulos (incluindo a fiação de injeção de dependência do `flutter_modular`). -

- screenshot-1 - screenshot-2 - screenshot-3 - screenshot-4 -

+### Temas claro e escuro + +Cada calculadora tem a própria cor, e a resposta aparece em frase no topo da tela. O app segue o tema do sistema, e o botão de sol e lua na tela inicial troca o tema. A escolha fica salva. + + + + + + + + + + + + + + + + + + +
ClaroTela inicial, tema claroFinanciamento, tema claroDepósitos Regulares, tema claroValor Futuro, tema claroCorreção de Valores, tema claro
EscuroTela inicial, tema escuroFinanciamento, tema escuroDepósitos Regulares, tema escuroValor Futuro, tema escuroCorreção de Valores, tema escuro
## Tecnologias @@ -54,7 +68,7 @@ Essa lógica vive num Cubit dedicado por calculadora, reagindo em tempo real a c | `Dio` | Cliente HTTP | | `equatable` | Comparação de estados e objetos | | `intl` | Formatação de valores e datas | -| `google_fonts` | Tipografia (Manrope) | +| Manrope e Bricolage Grotesque | Tipografia empacotada no app para uso offline | | `mocktail` e `bloc_test` | Testes | **API de terceiros:** [API do Banco Central do Brasil](https://dadosabertos.bcb.gov.br/), usada como fonte de dados da taxa Selic. @@ -110,11 +124,11 @@ test/ # Testes organizados por módulo ## Roadmap -- [ ] Correção de valores por índice (IPCA, IGP-M, poupança) +- [x] Correção de valores por índice (IPCA, IGP-M, poupança) - [ ] Comparação SAC vs. Price - [ ] Simulação de amortização extra - [ ] Histórico de cálculos -- [ ] Modo claro +- [x] Modo claro ## Contribuindo diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..7e428b9 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,12 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4fcc831 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..5338911 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c065d18 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..dc383de Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index cbe3b2f..dae0427 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,13 +1,9 @@ - - - - - + + android:src="@mipmap/launch_image" /> diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..06b2cb3 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..00f3fb7 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e958880 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..0bc048a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..37ff290 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..1c55d3a Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 78107ad..dae0427 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,13 +1,9 @@ - - - - - + + android:src="@mipmap/launch_image" /> diff --git a/android/app/src/main/res/drawable/launch_image.png b/android/app/src/main/res/drawable/launch_image.png deleted file mode 100644 index d415e4e..0000000 Binary files a/android/app/src/main/res/drawable/launch_image.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c61e081 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index bc9e807..89488c2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launch_image.png b/android/app/src/main/res/mipmap-hdpi/launch_image.png new file mode 100644 index 0000000..781f8ef Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launch_image.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5a339dd..23180d2 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launch_image.png b/android/app/src/main/res/mipmap-mdpi/launch_image.png new file mode 100644 index 0000000..05a99ec Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launch_image.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 3949a4a..6c2821f 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launch_image.png b/android/app/src/main/res/mipmap-xhdpi/launch_image.png new file mode 100644 index 0000000..e1c87ab Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launch_image.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index f83edda..ccc5474 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launch_image.png b/android/app/src/main/res/mipmap-xxhdpi/launch_image.png new file mode 100644 index 0000000..84a1c25 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launch_image.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 641e87e..299c8e8 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launch_image.png b/android/app/src/main/res/mipmap-xxxhdpi/launch_image.png new file mode 100644 index 0000000..9f8bc33 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launch_image.png differ diff --git a/android/app/src/main/res/play_store_512.png b/android/app/src/main/res/play_store_512.png index d415e4e..224ab0b 100644 Binary files a/android/app/src/main/res/play_store_512.png and b/android/app/src/main/res/play_store_512.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..96ed68c --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..ee58347 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..2fbb901 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #14121F + #14121F + diff --git a/assets/branding/app_icon.svg b/assets/branding/app_icon.svg index ddf5840..97b912d 100644 --- a/assets/branding/app_icon.svg +++ b/assets/branding/app_icon.svg @@ -1,8 +1,10 @@ - - Ícone do app com um símbolo de porcentagem estilizado - Quadrado arredondado escuro com um traço diagonal âmbar, um anel no topo e duas barras ascendentes na base, formando um símbolo de porcentagem combinado com um gráfico de crescimento. - - - - - \ No newline at end of file + + Símbolo do RealCalc + Símbolo de porcentagem em âmbar com duas barras ascendentes, em traço único com pontas arredondadas. + + + + + + + diff --git a/assets/branding/app_icon_pequeno.svg b/assets/branding/app_icon_pequeno.svg new file mode 100644 index 0000000..69d8172 --- /dev/null +++ b/assets/branding/app_icon_pequeno.svg @@ -0,0 +1,10 @@ + + Símbolo do RealCalc para 24px ou menos + Versão do símbolo com traço mais grosso e espaços mais abertos, para continuar legível em tamanhos pequenos. + + + + + + + diff --git a/assets/fonts/Bricolage-Grotesque-OFL.txt b/assets/fonts/Bricolage-Grotesque-OFL.txt new file mode 100644 index 0000000..a397658 --- /dev/null +++ b/assets/fonts/Bricolage-Grotesque-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +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/BricolageGrotesque-ExtraBold.ttf b/assets/fonts/BricolageGrotesque-ExtraBold.ttf new file mode 100644 index 0000000..74950f2 Binary files /dev/null and b/assets/fonts/BricolageGrotesque-ExtraBold.ttf differ diff --git a/assets/fonts/Manrope-Bold.ttf b/assets/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000..746764d Binary files /dev/null and b/assets/fonts/Manrope-Bold.ttf differ diff --git a/assets/fonts/Manrope-ExtraBold.ttf b/assets/fonts/Manrope-ExtraBold.ttf new file mode 100644 index 0000000..d324b62 Binary files /dev/null and b/assets/fonts/Manrope-ExtraBold.ttf differ diff --git a/assets/fonts/Manrope-Medium.ttf b/assets/fonts/Manrope-Medium.ttf new file mode 100644 index 0000000..2a169e6 Binary files /dev/null and b/assets/fonts/Manrope-Medium.ttf differ diff --git a/assets/fonts/Manrope-OFL.txt b/assets/fonts/Manrope-OFL.txt new file mode 100644 index 0000000..e271172 --- /dev/null +++ b/assets/fonts/Manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/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/Manrope-SemiBold.ttf b/assets/fonts/Manrope-SemiBold.ttf new file mode 100644 index 0000000..595e7dc Binary files /dev/null and b/assets/fonts/Manrope-SemiBold.ttf differ diff --git a/assets/icons/alert.svg b/assets/icons/alert.svg new file mode 100644 index 0000000..5a3477c --- /dev/null +++ b/assets/icons/alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/arrow_down.svg b/assets/icons/arrow_down.svg new file mode 100644 index 0000000..8d0c5eb --- /dev/null +++ b/assets/icons/arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/arrow_up.svg b/assets/icons/arrow_up.svg new file mode 100644 index 0000000..114b5ca --- /dev/null +++ b/assets/icons/arrow_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/back.svg b/assets/icons/back.svg new file mode 100644 index 0000000..4034ace --- /dev/null +++ b/assets/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/bank.svg b/assets/icons/bank.svg new file mode 100644 index 0000000..ef6b2b1 --- /dev/null +++ b/assets/icons/bank.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000..195da74 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000..4373c8b --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_right.svg b/assets/icons/chevron_right.svg new file mode 100644 index 0000000..4495c34 --- /dev/null +++ b/assets/icons/chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/close.svg b/assets/icons/close.svg new file mode 100644 index 0000000..c30dc8c --- /dev/null +++ b/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/cycle.svg b/assets/icons/cycle.svg new file mode 100644 index 0000000..8d5ed8e --- /dev/null +++ b/assets/icons/cycle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/help.svg b/assets/icons/help.svg new file mode 100644 index 0000000..1ec8bbf --- /dev/null +++ b/assets/icons/help.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/moon.svg b/assets/icons/moon.svg new file mode 100644 index 0000000..f486300 --- /dev/null +++ b/assets/icons/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/piggy_bank.svg b/assets/icons/piggy_bank.svg new file mode 100644 index 0000000..88890b9 --- /dev/null +++ b/assets/icons/piggy_bank.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/reset.svg b/assets/icons/reset.svg new file mode 100644 index 0000000..a025005 --- /dev/null +++ b/assets/icons/reset.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..8efdea2 --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/share.svg b/assets/icons/share.svg new file mode 100644 index 0000000..ac9b409 --- /dev/null +++ b/assets/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/sun.svg b/assets/icons/sun.svg new file mode 100644 index 0000000..1cde33d --- /dev/null +++ b/assets/icons/sun.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/trending_up.svg b/assets/icons/trending_up.svg new file mode 100644 index 0000000..77cd100 --- /dev/null +++ b/assets/icons/trending_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/wifi_off.svg b/assets/icons/wifi_off.svg new file mode 100644 index 0000000..d05c931 --- /dev/null +++ b/assets/icons/wifi_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/launcher/adaptive_foreground_1024.png b/assets/launcher/adaptive_foreground_1024.png new file mode 100644 index 0000000..9904b69 Binary files /dev/null and b/assets/launcher/adaptive_foreground_1024.png differ diff --git a/assets/launcher/adaptive_monochrome_1024.png b/assets/launcher/adaptive_monochrome_1024.png new file mode 100644 index 0000000..c7ef6f8 Binary files /dev/null and b/assets/launcher/adaptive_monochrome_1024.png differ diff --git a/assets/launcher/icon_1024.png b/assets/launcher/icon_1024.png new file mode 100644 index 0000000..3f4d650 Binary files /dev/null and b/assets/launcher/icon_1024.png differ diff --git a/assets/readme/banner.png b/assets/readme/banner.png index 9056b93..8543838 100644 Binary files a/assets/readme/banner.png and b/assets/readme/banner.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..bbfbe0a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..4846259 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..b390e08 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..d002e34 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..b89c4db 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..262d09c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..7961339 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..b390e08 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..00c5780 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..a967c02 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..aece911 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..4e3612c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..18f42d8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..6be07ee Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..a967c02 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..c10edda 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..89488c2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..ccc5474 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..eaae3a6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..d2fc8a2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..db97955 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9da19ea..05a99ec 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 9da19ea..e1c87ab 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 9da19ea..84a1c25 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..14f7439 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -19,7 +19,7 @@ - + diff --git a/lib/core/routes/app_routes.dart b/lib/core/routes/app_routes.dart index 6591757..7f7fbe3 100644 --- a/lib/core/routes/app_routes.dart +++ b/lib/core/routes/app_routes.dart @@ -9,17 +9,10 @@ class AppRoutes { static const String futureValueSegment = 'future_value'; static const String depositsSegment = 'deposits'; static const String correctionSegment = 'correction'; - static const String correctionResultSegment = 'result'; - static const String historySegment = 'history'; - static const String profileSegment = 'profile'; // Absolutos para navegação static const String financing = '/home/$financingSegment'; static const String futureValue = '$financing/$futureValueSegment'; static const String deposits = '$financing/$depositsSegment'; static const String correction = '/$correctionSegment/'; - static const String correctionResult = - '/$correctionSegment/$correctionResultSegment'; - static const String history = '/home/$historySegment'; - static const String profile = '/home/$profileSegment'; } diff --git a/lib/core/services/result_sharer.dart b/lib/core/services/result_sharer.dart new file mode 100644 index 0000000..128fb6d --- /dev/null +++ b/lib/core/services/result_sharer.dart @@ -0,0 +1,6 @@ +import 'dart:ui'; + +abstract interface class ResultSharer { + /// [origin] ancora o popover no iPad. + Future share(String text, {Rect? origin}); +} diff --git a/lib/core/services/share_plus_result_sharer.dart b/lib/core/services/share_plus_result_sharer.dart new file mode 100644 index 0000000..ec573d4 --- /dev/null +++ b/lib/core/services/share_plus_result_sharer.dart @@ -0,0 +1,16 @@ +import 'dart:ui'; + +import 'package:share_plus/share_plus.dart'; + +import 'result_sharer.dart'; + +class SharePlusResultSharer implements ResultSharer { + const SharePlusResultSharer(); + + @override + Future share(String text, {Rect? origin}) async { + await SharePlus.instance.share( + ShareParams(text: text, sharePositionOrigin: origin), + ); + } +} diff --git a/lib/core/themes/app_colors.dart b/lib/core/themes/app_colors.dart new file mode 100644 index 0000000..423a093 --- /dev/null +++ b/lib/core/themes/app_colors.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +class AppColors extends ThemeExtension { + final Color background; + final Color sheet; + final Color elevated; + final Color hairline; + final Color textPrimary; + final Color textSecondary; + final Color textHint; + final Color error; + final Color scrim; + final Color bandHome; + final Color onBandSoft; + final Color brandAmber; + final Color onCalc; + + const AppColors({ + required this.background, + required this.sheet, + required this.elevated, + required this.hairline, + required this.textPrimary, + required this.textSecondary, + required this.textHint, + required this.error, + required this.scrim, + required this.bandHome, + required this.onBandSoft, + required this.brandAmber, + required this.onCalc, + }); + + static const light = AppColors( + background: Color(0xFFFFFFFF), + sheet: Color(0xFFFFFFFF), + elevated: Color(0xFFF2F2F5), + hairline: Color(0xFFE8E8EE), + textPrimary: Color(0xFF14121F), + textSecondary: Color(0xFF57546A), + textHint: Color(0xFF6E6B82), + error: Color(0xFFC92A2A), + scrim: Color(0x7314121F), + bandHome: Color(0xFF14121F), + onBandSoft: Color(0xFFB4B1C4), + brandAmber: Color(0xFFD4A657), + onCalc: Color(0xFFFFFFFF), + ); + + static const dark = AppColors( + background: Color(0xFF100E19), + sheet: Color(0xFF1B1926), + elevated: Color(0xFF262333), + hairline: Color(0x17FFFFFF), + textPrimary: Color(0xFFF5F4FA), + textSecondary: Color(0xFFB4B1C4), + textHint: Color(0xFF918DA5), + error: Color(0xFFFF8A8A), + scrim: Color(0xA805040A), + bandHome: Color(0xFF221F2E), + onBandSoft: Color(0xFFB4B1C4), + brandAmber: Color(0xFFD4A657), + onCalc: Color(0xFFFFFFFF), + ); + + /// Cores do tema atual; sem a extensão registrada, escolhe pelo brilho. + static AppColors of(BuildContext context) { + final theme = Theme.of(context); + return theme.extension() ?? + (theme.brightness == Brightness.dark ? dark : light); + } + + @override + AppColors copyWith({ + Color? background, + Color? sheet, + Color? elevated, + Color? hairline, + Color? textPrimary, + Color? textSecondary, + Color? textHint, + Color? error, + Color? scrim, + Color? bandHome, + Color? onBandSoft, + Color? brandAmber, + Color? onCalc, + }) { + return AppColors( + background: background ?? this.background, + sheet: sheet ?? this.sheet, + elevated: elevated ?? this.elevated, + hairline: hairline ?? this.hairline, + textPrimary: textPrimary ?? this.textPrimary, + textSecondary: textSecondary ?? this.textSecondary, + textHint: textHint ?? this.textHint, + error: error ?? this.error, + scrim: scrim ?? this.scrim, + bandHome: bandHome ?? this.bandHome, + onBandSoft: onBandSoft ?? this.onBandSoft, + brandAmber: brandAmber ?? this.brandAmber, + onCalc: onCalc ?? this.onCalc, + ); + } + + @override + AppColors lerp(ThemeExtension? other, double t) { + if (other is! AppColors) return this; + return AppColors( + background: Color.lerp(background, other.background, t)!, + sheet: Color.lerp(sheet, other.sheet, t)!, + elevated: Color.lerp(elevated, other.elevated, t)!, + hairline: Color.lerp(hairline, other.hairline, t)!, + textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!, + textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!, + textHint: Color.lerp(textHint, other.textHint, t)!, + error: Color.lerp(error, other.error, t)!, + scrim: Color.lerp(scrim, other.scrim, t)!, + bandHome: Color.lerp(bandHome, other.bandHome, t)!, + onBandSoft: Color.lerp(onBandSoft, other.onBandSoft, t)!, + brandAmber: Color.lerp(brandAmber, other.brandAmber, t)!, + onCalc: Color.lerp(onCalc, other.onCalc, t)!, + ); + } +} diff --git a/lib/core/themes/app_image.dart b/lib/core/themes/app_image.dart index c01459c..bc17432 100644 --- a/lib/core/themes/app_image.dart +++ b/lib/core/themes/app_image.dart @@ -1,5 +1,6 @@ class AppImage { - static const String _branding = 'assets/branding/'; + static const String _branding = 'assets/branding'; static const String appLogo = '$_branding/app_icon.svg'; -} \ No newline at end of file + static const String appLogoSmall = '$_branding/app_icon_pequeno.svg'; +} diff --git a/lib/core/themes/app_theme.dart b/lib/core/themes/app_theme.dart index ad0376b..b36d0d9 100644 --- a/lib/core/themes/app_theme.dart +++ b/lib/core/themes/app_theme.dart @@ -1,297 +1,88 @@ import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/help_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/highlight_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/menu_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/options_bottom_forms_theme.dart'; -import 'color_tokens.dart'; -import 'extensions/financing_forms_theme.dart'; -import 'extensions/home_page_theme.dart'; -import 'extensions/index_picker_theme.dart'; -import 'extensions/input_forms_result_card_theme.dart'; -import 'extensions/metric_card_theme.dart'; -import 'extensions/page_header_theme.dart'; -import 'extensions/title_widget_theme.dart'; -import 'extensions/value_correction_result_theme.dart'; -import 'extensions/warning_banner_theme.dart'; -import 'extensions/error_banner_theme.dart'; +import 'package:flutter/services.dart'; + +import 'app_colors.dart'; import 'text_styles.dart'; class AppTheme { static ThemeData get darkTheme { + const colors = AppColors.dark; return ThemeData( brightness: Brightness.dark, useMaterial3: true, + fontFamily: AppTextStyles.manropeFamily, // Cores base - scaffoldBackgroundColor: ColorTokens.background, + scaffoldBackgroundColor: colors.background, // ColorScheme do Material 3 - colorScheme: const ColorScheme.dark( - surface: ColorTokens.surface, - error: ColorTokens.error, - onPrimary: Colors.white, - onSecondary: Colors.white, - onSurface: ColorTokens.textPrimary, + colorScheme: ColorScheme.dark( + primary: colors.textPrimary, + surface: colors.sheet, + error: colors.error, + onPrimary: colors.background, + onSecondary: colors.background, + onSurface: colors.textPrimary, onError: Colors.black, ), - - // AppBar global - appBarTheme: AppBarTheme( - backgroundColor: ColorTokens.surface, - elevation: 0, - centerTitle: true, - actionsIconTheme: const IconThemeData(color: ColorTokens.accentAmber), - titleTextStyle: AppTextStyles.headlineMedium, - iconTheme: IconThemeData(color: ColorTokens.iconColor), - ), - - // TextTheme - textTheme: TextTheme( - headlineLarge: AppTextStyles.headlineLarge, - headlineMedium: AppTextStyles.headlineMedium, - bodyLarge: AppTextStyles.bodyLarge, - bodyMedium: AppTextStyles.bodyMedium, - labelLarge: AppTextStyles.labelLarge, - ), - - // Inputs - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: ColorTokens.background, - hintStyle: TextStyle(color: ColorTokens.textHint), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: ColorTokens.background, - width: 1.0, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: ColorTokens.background, - width: 1.5, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: ColorTokens.error, width: 1.0), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: ColorTokens.error, width: 1.5), - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 18, - horizontal: 16, - ), - ), - - // Botões elevados - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: ColorTokens.surface, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - textStyle: AppTextStyles.labelLarge, - ), - ), - - // BottomNavigationBar - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: ColorTokens.background, - selectedItemColor: ColorTokens.surface, - unselectedItemColor: ColorTokens.textSecondary, - type: BottomNavigationBarType.fixed, - elevation: 8, + textSelectionTheme: TextSelectionThemeData( + cursorColor: colors.textPrimary, + selectionColor: colors.textPrimary.withValues(alpha: 0.32), + selectionHandleColor: colors.textPrimary, ), - // SnackBar snackBarTheme: SnackBarThemeData( - backgroundColor: ColorTokens.surface, - contentTextStyle: const TextStyle(color: ColorTokens.textPrimary), + backgroundColor: colors.bandHome, + contentTextStyle: AppTextStyles.detailLabel.copyWith( + color: Colors.white, + ), + actionTextColor: colors.brandAmber, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), behavior: SnackBarBehavior.floating, ), - extensions: [ - const OptionsBottomFormsTheme( - calculateButtonBackground: ColorTokens.accentAmber, - calculateButtonForeground: Colors.white, - actionButtonBackground: ColorTokens.background, - actionButtonForeground: ColorTokens.textSecondary, - borderButtonColor: ColorTokens.successContainerBorder, - ), - TitleWidgetTheme( - titleStyle: AppTextStyles.headlineMedium.copyWith( - color: ColorTokens.textPrimary, - fontWeight: FontWeight.bold, - ), - subtitleStyle: AppTextStyles.bodyMedium.copyWith( - color: ColorTokens.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - HelpCardTheme( - messageStyle: AppTextStyles.helpCardMessage, - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber, - iconBackgroundColor: ColorTokens.surface, - iconColor: Colors.white, - ), - // app_theme.dart (dentro de extensions:) - InputFormsResultCardTheme( - neutralBorderColor: ColorTokens.border, - neutralLabelColor: ColorTokens.textSecondary, - neutralIconColor: ColorTokens.textSecondary, - highlightedColor: ColorTokens.accentAmber, - calculatedColor: ColorTokens.success, - errorColor: ColorTokens.error, - badgeStyle: AppTextStyles.stateBadge, - ), - IndexPickerTheme( - backgroundColor: ColorTokens.surface, - surfaceColor: ColorTokens.background, - dividerColor: ColorTokens.border, - selectedColor: ColorTokens.accentAmber.withValues(alpha: 0.12), - selectedBorderColor: ColorTokens.accentAmber, - titleColor: ColorTokens.textPrimary, - subtitleColor: ColorTokens.textSecondary, - checkColor: ColorTokens.accentAmber, - sectionTitleStyle: AppTextStyles.labelLarge.copyWith( - color: ColorTokens.textSecondary, - letterSpacing: 1.2, - ), - itemTitleStyle: AppTextStyles.bodyLarge.copyWith( - color: ColorTokens.textPrimary, - fontWeight: FontWeight.w600, - ), - itemSubtitleStyle: AppTextStyles.bodyMedium.copyWith( - color: ColorTokens.textSecondary, - ), - ), - HomePageTheme( - scaffoldBackgroundColor: ColorTokens.background, - cardColor: ColorTokens.background, - iconContainerColor: ColorTokens.iconColor, - appNameStyle: AppTextStyles.appName, - sectionHeaderStyle: AppTextStyles.sectionHeader, - historyItemStyle: AppTextStyles.historyItem, - historyItemIconColor: Colors.white, - historyItemArrowColor: Colors.white, - bottomNavBackgroundColor: ColorTokens.surface, - bottomNavSelectedColor: ColorTokens.accentAmber, - bottomNavUnselectedColor: Colors.white, - lineColor: ColorTokens.border, - ), - FinancingFormsTheme( - iconColor: ColorTokens.iconColor, - progressIndicatorColor: ColorTokens.accentAmber, - errorBackgroundColor: ColorTokens.errorContainerBg, - errorBorderColor: ColorTokens.errorContainerBorder, - errorTextStyle: AppTextStyles.errorBannerText, - ), - WarningBannerTheme( - backgroundColor: ColorTokens.errorContainerBg, - borderColor: ColorTokens.errorContainerBorder, - iconColor: ColorTokens.error, - textStyle: AppTextStyles.errorBannerText, - ), - ErrorBannerTheme( - backgroundColor: ColorTokens.errorContainerBg, - borderColor: ColorTokens.errorContainerBorder, - iconColor: ColorTokens.errorContainerBorder, - retryTextColor: Colors.white, - messageStyle: AppTextStyles.errorBannerText, - ), - HighlightCardTheme( - gradientColors: [ - ColorTokens.surface, - ColorTokens.accentAmber.withValues(alpha: 0.32), - ], - titleStyle: AppTextStyles.menuCardTitleFeatured, - subtitleStyle: AppTextStyles.menuCardDescriptionFeatured, - buttonBackgroundColor: Colors.white, - buttonForegroundColor: Colors.white, - buttonTextStyle: AppTextStyles.menuCardDescriptionFeatured, - ), - - MenuCardTheme( - titleStyle: AppTextStyles.menuCardTitle, - descriptionStyle: AppTextStyles.menuCardDescription, - titleStyleCompact: AppTextStyles.menuCardTitleCompact, - descriptionStyleCompact: AppTextStyles.menuCardDescriptionCompact, - titleStyleFeatured: AppTextStyles.menuCardTitleFeatured, - descriptionStyleFeatured: AppTextStyles.menuCardDescriptionFeatured, - featuredBorderColor: ColorTokens.accentAmber, - disabledLabelColor: ColorTokens.textHint, - disabledTextStyle: AppTextStyles.menuCardDescription, - ), - - MetricCardTheme( - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber.withValues(alpha: 0.25), - labelStyle: AppTextStyles.metricLabel, - valueStyle: AppTextStyles.metricValue, - valueUnitStyle: AppTextStyles.metricLabel, - captionStyle: AppTextStyles.metricCaption, - variationTextStyle: AppTextStyles.stateBadge, - variationPositiveColor: ColorTokens.success, - variationNegativeColor: ColorTokens.error, - variationNeutralColor: ColorTokens.textSecondary, - skeletonColor: ColorTokens.surfaceVariant, - ), - PageHeaderTheme( - backgroundColor: ColorTokens.background, - iconColor: ColorTokens.textSecondary, - titleStyle: AppTextStyles.headlineMedium.copyWith( - color: ColorTokens.textPrimary, - ), - ), - ValueCorrectionResultTheme( - cardBackgroundColor: ColorTokens.background, - cardBorderColor: ColorTokens.border, - spotlightBackground: ColorTokens.successContainerBg, - spotlightBorderColor: ColorTokens.successContainerBorder, - variationPositiveColor: ColorTokens.success, - shareButtonColor: ColorTokens.accentAmber, - editButtonColor: ColorTokens.textPrimary, - titleStyle: AppTextStyles.headlineMedium.copyWith( - color: ColorTokens.textPrimary, - ), - subtitleStyle: AppTextStyles.bodyMedium.copyWith( - color: ColorTokens.textSecondary, - ), - spotlightLabelStyle: AppTextStyles.labelLarge.copyWith( - color: ColorTokens.textPrimary, - ), - spotlightValueStyle: AppTextStyles.headlineLarge.copyWith( - color: ColorTokens.textPrimary, - fontSize: 32, - fontWeight: FontWeight.w700, - ), - spotlightVariationStyle: AppTextStyles.stateBadge.copyWith( - color: ColorTokens.success, - fontWeight: FontWeight.w600, - ), - detailLabelStyle: AppTextStyles.bodyMedium.copyWith( - color: ColorTokens.textSecondary, - ), - detailValueStyle: AppTextStyles.bodyLarge.copyWith( - color: ColorTokens.textPrimary, - fontWeight: FontWeight.w500, - ), - linkStyle: AppTextStyles.bodyMedium.copyWith( - color: ColorTokens.textPrimary, - decoration: TextDecoration.underline, - ), - ), - ], + extensions: const [colors], ); } static ThemeData get lightTheme { - return darkTheme; + final base = darkTheme; + const colors = AppColors.light; + return base.copyWith( + brightness: Brightness.light, + scaffoldBackgroundColor: colors.background, + colorScheme: ColorScheme.light( + primary: colors.textPrimary, + surface: colors.sheet, + error: colors.error, + onPrimary: colors.onCalc, + onSecondary: colors.onCalc, + onSurface: colors.textPrimary, + onError: colors.onCalc, + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: colors.textPrimary, + selectionColor: colors.textPrimary.withValues(alpha: 0.32), + selectionHandleColor: colors.textPrimary, + ), + snackBarTheme: base.snackBarTheme.copyWith( + backgroundColor: colors.bandHome, + ), + extensions: const [colors], + ); + } + + static SystemUiOverlayStyle systemUiOverlayStyle(AppColors colors) { + return SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: colors.background, + systemNavigationBarIconBrightness: colors == AppColors.light + ? Brightness.dark + : Brightness.light, + systemNavigationBarDividerColor: colors.background, + systemNavigationBarContrastEnforced: false, + ); } } diff --git a/lib/core/themes/calculator_palette.dart b/lib/core/themes/calculator_palette.dart new file mode 100644 index 0000000..5012a41 --- /dev/null +++ b/lib/core/themes/calculator_palette.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +enum CalculatorKind { financing, deposits, futureValue, correction } + +class CalculatorPalette { + final Color solid; + final Color onSolid; + final Color text; + + const CalculatorPalette({ + required this.solid, + required this.onSolid, + required this.text, + }); + + static CalculatorPalette of( + CalculatorKind kind, { + Brightness brightness = Brightness.light, + }) { + final isDark = brightness == Brightness.dark; + return switch (kind) { + CalculatorKind.financing => CalculatorPalette( + solid: const Color(0xFF364FC7), + onSolid: Colors.white, + text: Color(isDark ? 0xFF91A7FF : 0xFF364FC7), + ), + CalculatorKind.deposits => CalculatorPalette( + solid: const Color(0xFF087F5B), + onSolid: Colors.white, + text: Color(isDark ? 0xFF63E6BE : 0xFF0A6E50), + ), + CalculatorKind.futureValue => CalculatorPalette( + solid: const Color(0xFF6741D9), + onSolid: Colors.white, + text: Color(isDark ? 0xFFB197FC : 0xFF6741D9), + ), + CalculatorKind.correction => CalculatorPalette( + solid: const Color(0xFFB85300), + onSolid: Colors.white, + text: Color(isDark ? 0xFFFFA94D : 0xFFA04600), + ), + }; + } + + @override + bool operator ==(Object other) => + other is CalculatorPalette && + other.solid == solid && + other.onSolid == onSolid && + other.text == text; + + @override + int get hashCode => Object.hash(solid, onSolid, text); +} diff --git a/lib/core/themes/calculator_scope.dart b/lib/core/themes/calculator_scope.dart new file mode 100644 index 0000000..946184e --- /dev/null +++ b/lib/core/themes/calculator_scope.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'calculator_palette.dart'; + +class CalculatorScope extends StatelessWidget { + final CalculatorKind kind; + final Widget child; + + const CalculatorScope({required this.kind, required this.child, super.key}); + + static CalculatorPalette? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType<_CalculatorPaletteScope>() + ?.palette; + + static CalculatorPalette of(BuildContext context) { + final palette = maybeOf(context); + assert( + palette != null, + 'CalculatorScope não encontrado na árvore de widgets.', + ); + return palette!; + } + + @override + Widget build(BuildContext context) { + final palette = CalculatorPalette.of( + kind, + brightness: Theme.of(context).brightness, + ); + final theme = Theme.of(context); + final localTheme = theme.copyWith( + colorScheme: theme.colorScheme.copyWith(primary: palette.solid), + textSelectionTheme: TextSelectionThemeData( + cursorColor: palette.solid, + selectionColor: palette.solid.withValues(alpha: 0.32), + selectionHandleColor: palette.solid, + ), + ); + + return Theme( + data: localTheme, + child: _CalculatorPaletteScope(palette: palette, child: child), + ); + } +} + +class _CalculatorPaletteScope extends InheritedWidget { + final CalculatorPalette palette; + + const _CalculatorPaletteScope({required this.palette, required super.child}); + + @override + bool updateShouldNotify(_CalculatorPaletteScope oldWidget) => + palette != oldWidget.palette; +} diff --git a/lib/core/themes/color_tokens.dart b/lib/core/themes/color_tokens.dart deleted file mode 100644 index 099e548..0000000 --- a/lib/core/themes/color_tokens.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -abstract class ColorTokens { - // Superfícies - static const Color background = Color(0xFF0A0D12); - static const Color surface = Color(0xFF0E1116); - static const Color surfaceVariant = Color(0xFF12161D); - - // Texto - static const Color textPrimary = Color(0xFFEDEFF2); - static const Color textSecondary = Color(0xFF8B929C); - static const Color textHint = Color(0xFF5C636E); - - // Borda - static const Color border = Color.fromARGB(20, 255, 255, 255); - - static const Color accentAmber = Color(0xFFD4A657); - - // Feedback - static const Color error = Color(0xFFE24B4A); - static const Color success = Color(0xFF6FCF97); - static const Color warning = Color(0xFFFFC107); - - // Container de erro - static const Color errorContainerBg = Color.fromARGB(25, 226, 75, 74); - static const Color errorContainerBorder = Color.fromARGB(25, 226, 75, 74); - - // Container de success - static const Color successContainerBg = Color.fromARGB(25, 111, 207, 151); - static const Color successContainerBorder = Color.fromARGB(25, 111, 207, 151); - - //icons - static const Color iconColor = Color(0xFFFFFFFF); -} \ No newline at end of file diff --git a/lib/core/themes/extensions/error_banner_theme.dart b/lib/core/themes/extensions/error_banner_theme.dart deleted file mode 100644 index 1c180be..0000000 --- a/lib/core/themes/extensions/error_banner_theme.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; - -class ErrorBannerTheme extends ThemeExtension { - final Color backgroundColor; - final Color borderColor; - final Color iconColor; - final Color retryTextColor; - final TextStyle messageStyle; - final EdgeInsetsGeometry padding; - final double borderRadius; - final double iconSize; - final double iconMessageSpacing; - final double retrySpacing; - - const ErrorBannerTheme({ - required this.backgroundColor, - required this.borderColor, - required this.iconColor, - required this.retryTextColor, - required this.messageStyle, - this.padding = const EdgeInsets.all(12), - this.borderRadius = 12, - this.iconSize = 18, - this.iconMessageSpacing = 10, - this.retrySpacing = 8, - }); - - @override - ErrorBannerTheme copyWith({ - Color? backgroundColor, - Color? borderColor, - Color? iconColor, - Color? retryTextColor, - TextStyle? messageStyle, - EdgeInsetsGeometry? padding, - double? borderRadius, - double? iconSize, - double? iconMessageSpacing, - double? retrySpacing, - }) { - return ErrorBannerTheme( - backgroundColor: backgroundColor ?? this.backgroundColor, - borderColor: borderColor ?? this.borderColor, - iconColor: iconColor ?? this.iconColor, - retryTextColor: retryTextColor ?? this.retryTextColor, - messageStyle: messageStyle ?? this.messageStyle, - padding: padding ?? this.padding, - borderRadius: borderRadius ?? this.borderRadius, - iconSize: iconSize ?? this.iconSize, - iconMessageSpacing: iconMessageSpacing ?? this.iconMessageSpacing, - retrySpacing: retrySpacing ?? this.retrySpacing, - ); - } - - @override - ErrorBannerTheme lerp( - covariant ThemeExtension? other, - double t, - ) { - if (other is! ErrorBannerTheme) return this; - - return ErrorBannerTheme( - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - borderColor: Color.lerp(borderColor, other.borderColor, t)!, - iconColor: Color.lerp(iconColor, other.iconColor, t)!, - retryTextColor: Color.lerp(retryTextColor, other.retryTextColor, t)!, - messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t)!, - padding: EdgeInsetsGeometry.lerp(padding, other.padding, t)!, - borderRadius: borderRadius + (other.borderRadius - borderRadius) * t, - iconSize: iconSize + (other.iconSize - iconSize) * t, - iconMessageSpacing: - iconMessageSpacing + - (other.iconMessageSpacing - iconMessageSpacing) * t, - retrySpacing: retrySpacing + (other.retrySpacing - retrySpacing) * t, - ); - } -} diff --git a/lib/core/themes/extensions/financing_forms_theme.dart b/lib/core/themes/extensions/financing_forms_theme.dart deleted file mode 100644 index 1cfa1b0..0000000 --- a/lib/core/themes/extensions/financing_forms_theme.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -class FinancingFormsTheme extends ThemeExtension { - final Color iconColor; - final Color progressIndicatorColor; - final Color errorBackgroundColor; - final Color errorBorderColor; - final TextStyle errorTextStyle; - final EdgeInsetsGeometry errorContainerPadding; - - const FinancingFormsTheme({ - required this.iconColor, - required this.progressIndicatorColor, - required this.errorBackgroundColor, - required this.errorBorderColor, - required this.errorTextStyle, - this.errorContainerPadding = const EdgeInsets.all(12), - }); - - @override - ThemeExtension copyWith({ - Color? iconColor, - Color? progressIndicatorColor, - Color? errorBackgroundColor, - Color? errorBorderColor, - TextStyle? errorTextStyle, - EdgeInsetsGeometry? errorContainerPadding, - }) { - return FinancingFormsTheme( - iconColor: iconColor ?? this.iconColor, - progressIndicatorColor: - progressIndicatorColor ?? this.progressIndicatorColor, - errorBackgroundColor: - errorBackgroundColor ?? this.errorBackgroundColor, - errorBorderColor: errorBorderColor ?? this.errorBorderColor, - errorTextStyle: errorTextStyle ?? this.errorTextStyle, - errorContainerPadding: - errorContainerPadding ?? this.errorContainerPadding, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! FinancingFormsTheme) return this; - return FinancingFormsTheme( - iconColor: Color.lerp(iconColor, other.iconColor, t)!, - progressIndicatorColor: - Color.lerp(progressIndicatorColor, other.progressIndicatorColor, t)!, - errorBackgroundColor: - Color.lerp(errorBackgroundColor, other.errorBackgroundColor, t)!, - errorBorderColor: - Color.lerp(errorBorderColor, other.errorBorderColor, t)!, - errorTextStyle: - TextStyle.lerp(errorTextStyle, other.errorTextStyle, t)!, - errorContainerPadding: other.errorContainerPadding, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/help_card_theme.dart b/lib/core/themes/extensions/help_card_theme.dart deleted file mode 100644 index b50ceda..0000000 --- a/lib/core/themes/extensions/help_card_theme.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -class HelpCardTheme extends ThemeExtension { - final TextStyle messageStyle; - final Color backgroundColor; - final Color borderColor; - final Color iconBackgroundColor; - final Color iconColor; - final double iconSize; - final double borderRadius; - final EdgeInsetsGeometry padding; - - const HelpCardTheme({ - required this.messageStyle, - required this.backgroundColor, - required this.borderColor, - required this.iconBackgroundColor, - required this.iconColor, - this.iconSize = 22, - this.borderRadius = 18, - this.padding = const EdgeInsets.all(16), - }); - - @override - ThemeExtension copyWith({ - TextStyle? messageStyle, - Color? backgroundColor, - Color? borderColor, - Color? iconBackgroundColor, - Color? iconColor, - double? iconSize, - double? borderRadius, - EdgeInsetsGeometry? padding, - }) { - return HelpCardTheme( - messageStyle: messageStyle ?? this.messageStyle, - backgroundColor: backgroundColor ?? this.backgroundColor, - borderColor: borderColor ?? this.borderColor, - iconBackgroundColor: iconBackgroundColor ?? this.iconBackgroundColor, - iconColor: iconColor ?? this.iconColor, - iconSize: iconSize ?? this.iconSize, - borderRadius: borderRadius ?? this.borderRadius, - padding: padding ?? this.padding, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! HelpCardTheme) return this; - return HelpCardTheme( - messageStyle: TextStyle.lerp(messageStyle, other.messageStyle, t)!, - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - borderColor: Color.lerp(borderColor, other.borderColor, t)!, - iconBackgroundColor: - Color.lerp(iconBackgroundColor, other.iconBackgroundColor, t)!, - iconColor: Color.lerp(iconColor, other.iconColor, t)!, - iconSize: other.iconSize, - borderRadius: other.borderRadius, - padding: other.padding, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/highlight_card_theme.dart b/lib/core/themes/extensions/highlight_card_theme.dart deleted file mode 100644 index 0ba29d7..0000000 --- a/lib/core/themes/extensions/highlight_card_theme.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; - -class HighlightCardTheme extends ThemeExtension { - final List gradientColors; - final AlignmentGeometry gradientBegin; - final AlignmentGeometry gradientEnd; - final TextStyle titleStyle; - final TextStyle subtitleStyle; - final Color buttonBackgroundColor; - final Color buttonForegroundColor; - final TextStyle buttonTextStyle; - final double borderRadius; - final EdgeInsetsGeometry padding; - - const HighlightCardTheme({ - required this.gradientColors, - this.gradientBegin = Alignment.topLeft, - this.gradientEnd = Alignment.bottomRight, - required this.titleStyle, - required this.subtitleStyle, - required this.buttonBackgroundColor, - required this.buttonForegroundColor, - required this.buttonTextStyle, - this.borderRadius = 20, - this.padding = const EdgeInsets.all(24), - }); - - @override - ThemeExtension copyWith({ - List? gradientColors, - AlignmentGeometry? gradientBegin, - AlignmentGeometry? gradientEnd, - TextStyle? titleStyle, - TextStyle? subtitleStyle, - Color? buttonBackgroundColor, - Color? buttonForegroundColor, - TextStyle? buttonTextStyle, - double? borderRadius, - EdgeInsetsGeometry? padding, - }) { - return HighlightCardTheme( - gradientColors: gradientColors ?? this.gradientColors, - gradientBegin: gradientBegin ?? this.gradientBegin, - gradientEnd: gradientEnd ?? this.gradientEnd, - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - buttonBackgroundColor: - buttonBackgroundColor ?? this.buttonBackgroundColor, - buttonForegroundColor: - buttonForegroundColor ?? this.buttonForegroundColor, - buttonTextStyle: buttonTextStyle ?? this.buttonTextStyle, - borderRadius: borderRadius ?? this.borderRadius, - padding: padding ?? this.padding, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! HighlightCardTheme) return this; - return HighlightCardTheme( - gradientColors: other.gradientColors, - gradientBegin: other.gradientBegin, - gradientEnd: other.gradientEnd, - titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t)!, - subtitleStyle: TextStyle.lerp(subtitleStyle, other.subtitleStyle, t)!, - buttonBackgroundColor: - Color.lerp(buttonBackgroundColor, other.buttonBackgroundColor, t)!, - buttonForegroundColor: - Color.lerp(buttonForegroundColor, other.buttonForegroundColor, t)!, - buttonTextStyle: - TextStyle.lerp(buttonTextStyle, other.buttonTextStyle, t)!, - borderRadius: other.borderRadius, - padding: other.padding, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/home_page_theme.dart b/lib/core/themes/extensions/home_page_theme.dart deleted file mode 100644 index b269e3e..0000000 --- a/lib/core/themes/extensions/home_page_theme.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; - -class HomePageTheme extends ThemeExtension { - final Color scaffoldBackgroundColor; - final Color cardColor; - final Color iconContainerColor; - final TextStyle appNameStyle; - final TextStyle sectionHeaderStyle; - final TextStyle historyItemStyle; - final Color historyItemIconColor; - final Color historyItemArrowColor; - final Color bottomNavBackgroundColor; - final Color bottomNavSelectedColor; - final Color bottomNavUnselectedColor; - final Color lineColor; - - const HomePageTheme({ - required this.scaffoldBackgroundColor, - required this.cardColor, - required this.iconContainerColor, - required this.appNameStyle, - required this.sectionHeaderStyle, - required this.historyItemStyle, - required this.historyItemIconColor, - required this.historyItemArrowColor, - required this.bottomNavBackgroundColor, - required this.bottomNavSelectedColor, - required this.bottomNavUnselectedColor, - required this.lineColor, - }); - - @override - ThemeExtension copyWith({ - Color? scaffoldBackgroundColor, - Color? cardColor, - Color? primaryBlue, - TextStyle? appNameStyle, - TextStyle? sectionHeaderStyle, - TextStyle? historyItemStyle, - Color? historyItemIconColor, - Color? historyItemArrowColor, - Color? bottomNavBackgroundColor, - Color? bottomNavSelectedColor, - Color? bottomNavUnselectedColor, - Color? iconContainerColor, - Color? lineColor, - }) { - return HomePageTheme( - scaffoldBackgroundColor: scaffoldBackgroundColor ?? this.scaffoldBackgroundColor, - cardColor: cardColor ?? this.cardColor, - iconContainerColor: iconContainerColor ?? this.iconContainerColor, - appNameStyle: appNameStyle ?? this.appNameStyle, - sectionHeaderStyle: sectionHeaderStyle ?? this.sectionHeaderStyle, - historyItemStyle: historyItemStyle ?? this.historyItemStyle, - historyItemIconColor: historyItemIconColor ?? this.historyItemIconColor, - historyItemArrowColor: historyItemArrowColor ?? this.historyItemArrowColor, - bottomNavBackgroundColor: bottomNavBackgroundColor ?? this.bottomNavBackgroundColor, - bottomNavSelectedColor: bottomNavSelectedColor ?? this.bottomNavSelectedColor, - bottomNavUnselectedColor: bottomNavUnselectedColor ?? this.bottomNavUnselectedColor, - lineColor: lineColor ?? this.lineColor, - ); - } - - @override - ThemeExtension lerp(ThemeExtension? other, double t) { - if (other is! HomePageTheme) return this; - return HomePageTheme( - scaffoldBackgroundColor: Color.lerp(scaffoldBackgroundColor, other.scaffoldBackgroundColor, t)!, - cardColor: Color.lerp(cardColor, other.cardColor, t)!, - iconContainerColor: Color.lerp(iconContainerColor, other.iconContainerColor, t)!, - appNameStyle: TextStyle.lerp(appNameStyle, other.appNameStyle, t)!, - sectionHeaderStyle: TextStyle.lerp(sectionHeaderStyle, other.sectionHeaderStyle, t)!, - historyItemStyle: TextStyle.lerp(historyItemStyle, other.historyItemStyle, t)!, - historyItemIconColor: Color.lerp(historyItemIconColor, other.historyItemIconColor, t)!, - historyItemArrowColor: Color.lerp(historyItemArrowColor, other.historyItemArrowColor, t)!, - bottomNavBackgroundColor: Color.lerp(bottomNavBackgroundColor, other.bottomNavBackgroundColor, t)!, - bottomNavSelectedColor: Color.lerp(bottomNavSelectedColor, other.bottomNavSelectedColor, t)!, - bottomNavUnselectedColor: Color.lerp(bottomNavUnselectedColor, other.bottomNavUnselectedColor, t)!, - lineColor: Color.lerp(lineColor, other.lineColor, t)!, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/index_picker_theme.dart b/lib/core/themes/extensions/index_picker_theme.dart deleted file mode 100644 index d30f006..0000000 --- a/lib/core/themes/extensions/index_picker_theme.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; - -class IndexPickerTheme extends ThemeExtension { - final Color backgroundColor; - final Color surfaceColor; - final Color dividerColor; - final Color selectedColor; - final Color selectedBorderColor; - final Color titleColor; - final Color subtitleColor; - final Color checkColor; - final TextStyle sectionTitleStyle; - final TextStyle itemTitleStyle; - final TextStyle itemSubtitleStyle; - - const IndexPickerTheme({ - required this.backgroundColor, - required this.surfaceColor, - required this.dividerColor, - required this.selectedColor, - required this.selectedBorderColor, - required this.titleColor, - required this.subtitleColor, - required this.checkColor, - required this.sectionTitleStyle, - required this.itemTitleStyle, - required this.itemSubtitleStyle, - }); - - @override - IndexPickerTheme copyWith({ - Color? backgroundColor, - Color? surfaceColor, - Color? dividerColor, - Color? selectedColor, - Color? selectedBorderColor, - Color? titleColor, - Color? subtitleColor, - Color? checkColor, - TextStyle? sectionTitleStyle, - TextStyle? itemTitleStyle, - TextStyle? itemSubtitleStyle, - }) { - return IndexPickerTheme( - backgroundColor: backgroundColor ?? this.backgroundColor, - surfaceColor: surfaceColor ?? this.surfaceColor, - dividerColor: dividerColor ?? this.dividerColor, - selectedColor: selectedColor ?? this.selectedColor, - selectedBorderColor: selectedBorderColor ?? this.selectedBorderColor, - titleColor: titleColor ?? this.titleColor, - subtitleColor: subtitleColor ?? this.subtitleColor, - checkColor: checkColor ?? this.checkColor, - sectionTitleStyle: sectionTitleStyle ?? this.sectionTitleStyle, - itemTitleStyle: itemTitleStyle ?? this.itemTitleStyle, - itemSubtitleStyle: itemSubtitleStyle ?? this.itemSubtitleStyle, - ); - } - - @override - IndexPickerTheme lerp(ThemeExtension? other, double t) { - if (other is! IndexPickerTheme) return this; - - return IndexPickerTheme( - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - surfaceColor: Color.lerp(surfaceColor, other.surfaceColor, t)!, - dividerColor: Color.lerp(dividerColor, other.dividerColor, t)!, - selectedColor: Color.lerp(selectedColor, other.selectedColor, t)!, - selectedBorderColor: - Color.lerp(selectedBorderColor, other.selectedBorderColor, t)!, - titleColor: Color.lerp(titleColor, other.titleColor, t)!, - subtitleColor: Color.lerp(subtitleColor, other.subtitleColor, t)!, - checkColor: Color.lerp(checkColor, other.checkColor, t)!, - sectionTitleStyle: - TextStyle.lerp(sectionTitleStyle, other.sectionTitleStyle, t)!, - itemTitleStyle: TextStyle.lerp(itemTitleStyle, other.itemTitleStyle, t)!, - itemSubtitleStyle: - TextStyle.lerp(itemSubtitleStyle, other.itemSubtitleStyle, t)!, - ); - } -} diff --git a/lib/core/themes/extensions/input_forms_result_card_theme.dart b/lib/core/themes/extensions/input_forms_result_card_theme.dart deleted file mode 100644 index 870c91a..0000000 --- a/lib/core/themes/extensions/input_forms_result_card_theme.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; - -class InputFormsResultCardTheme extends ThemeExtension { - final Color neutralBorderColor; - final Color neutralLabelColor; - final Color neutralIconColor; - final Color highlightedColor; - final Color calculatedColor; - final Color errorColor; - final TextStyle badgeStyle; - final double borderRadius; - - const InputFormsResultCardTheme({ - required this.neutralBorderColor, - required this.neutralLabelColor, - required this.neutralIconColor, - required this.highlightedColor, - required this.calculatedColor, - required this.errorColor, - required this.badgeStyle, - this.borderRadius = 14, - }); - - @override - InputFormsResultCardTheme copyWith({ - Color? neutralBorderColor, - Color? neutralLabelColor, - Color? neutralIconColor, - Color? highlightedColor, - Color? calculatedColor, - Color? errorColor, - TextStyle? badgeStyle, - double? borderRadius, - }) { - return InputFormsResultCardTheme( - neutralBorderColor: neutralBorderColor ?? this.neutralBorderColor, - neutralLabelColor: neutralLabelColor ?? this.neutralLabelColor, - neutralIconColor: neutralIconColor ?? this.neutralIconColor, - highlightedColor: highlightedColor ?? this.highlightedColor, - calculatedColor: calculatedColor ?? this.calculatedColor, - errorColor: errorColor ?? this.errorColor, - badgeStyle: badgeStyle ?? this.badgeStyle, - borderRadius: borderRadius ?? this.borderRadius, - ); - } - - @override - InputFormsResultCardTheme lerp( - ThemeExtension? other, - double t, - ) { - if (other is! InputFormsResultCardTheme) return this; - return InputFormsResultCardTheme( - neutralBorderColor: - Color.lerp(neutralBorderColor, other.neutralBorderColor, t)!, - neutralLabelColor: - Color.lerp(neutralLabelColor, other.neutralLabelColor, t)!, - neutralIconColor: - Color.lerp(neutralIconColor, other.neutralIconColor, t)!, - highlightedColor: Color.lerp(highlightedColor, other.highlightedColor, t)!, - calculatedColor: Color.lerp(calculatedColor, other.calculatedColor, t)!, - errorColor: Color.lerp(errorColor, other.errorColor, t)!, - badgeStyle: TextStyle.lerp(badgeStyle, other.badgeStyle, t)!, - borderRadius: borderRadius + (other.borderRadius - borderRadius) * t, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/menu_card_theme.dart b/lib/core/themes/extensions/menu_card_theme.dart deleted file mode 100644 index 3d4215f..0000000 --- a/lib/core/themes/extensions/menu_card_theme.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; - -class MenuCardTheme extends ThemeExtension { - // Variante standard (grid 2 colunas) - final TextStyle titleStyle; - final TextStyle descriptionStyle; - final TextStyle titleStyleCompact; - final TextStyle descriptionStyleCompact; - final double iconSize; - final double iconSizeCompact; - final double borderRadius; - final EdgeInsetsGeometry padding; - final EdgeInsetsGeometry compactPadding; - final double heightThreshold; - - // Variante featured (tile full-width, texto maior por ter mais espaço) - final TextStyle titleStyleFeatured; - final TextStyle descriptionStyleFeatured; - final Color featuredBorderColor; - - // Variante disabled - final Color disabledLabelColor; - final TextStyle disabledTextStyle; - - const MenuCardTheme({ - required this.titleStyle, - required this.descriptionStyle, - required this.titleStyleCompact, - required this.descriptionStyleCompact, - required this.titleStyleFeatured, - required this.descriptionStyleFeatured, - required this.featuredBorderColor, - required this.disabledLabelColor, - required this.disabledTextStyle, - this.iconSize = 24, - this.iconSizeCompact = 20, - this.borderRadius = 20, - this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - this.compactPadding = const EdgeInsets.all(8), - this.heightThreshold = 100, - }); - - @override - MenuCardTheme copyWith({ - TextStyle? titleStyle, - TextStyle? descriptionStyle, - TextStyle? titleStyleCompact, - TextStyle? descriptionStyleCompact, - TextStyle? titleStyleFeatured, - TextStyle? descriptionStyleFeatured, - Color? featuredBorderColor, - Color? disabledLabelColor, - TextStyle? disabledTextStyle, - double? iconSize, - double? iconSizeCompact, - double? borderRadius, - EdgeInsetsGeometry? padding, - EdgeInsetsGeometry? compactPadding, - double? heightThreshold, - }) { - return MenuCardTheme( - titleStyle: titleStyle ?? this.titleStyle, - descriptionStyle: descriptionStyle ?? this.descriptionStyle, - titleStyleCompact: titleStyleCompact ?? this.titleStyleCompact, - descriptionStyleCompact: - descriptionStyleCompact ?? this.descriptionStyleCompact, - titleStyleFeatured: titleStyleFeatured ?? this.titleStyleFeatured, - descriptionStyleFeatured: - descriptionStyleFeatured ?? this.descriptionStyleFeatured, - featuredBorderColor: featuredBorderColor ?? this.featuredBorderColor, - disabledLabelColor: disabledLabelColor ?? this.disabledLabelColor, - disabledTextStyle: disabledTextStyle ?? this.disabledTextStyle, - iconSize: iconSize ?? this.iconSize, - iconSizeCompact: iconSizeCompact ?? this.iconSizeCompact, - borderRadius: borderRadius ?? this.borderRadius, - padding: padding ?? this.padding, - compactPadding: compactPadding ?? this.compactPadding, - heightThreshold: heightThreshold ?? this.heightThreshold, - ); - } - - @override - MenuCardTheme lerp(ThemeExtension? other, double t) { - if (other is! MenuCardTheme) return this; - return MenuCardTheme( - titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t)!, - descriptionStyle: TextStyle.lerp( - descriptionStyle, - other.descriptionStyle, - t, - )!, - titleStyleCompact: TextStyle.lerp( - titleStyleCompact, - other.titleStyleCompact, - t, - )!, - descriptionStyleCompact: TextStyle.lerp( - descriptionStyleCompact, - other.descriptionStyleCompact, - t, - )!, - titleStyleFeatured: TextStyle.lerp( - titleStyleFeatured, - other.titleStyleFeatured, - t, - )!, - descriptionStyleFeatured: TextStyle.lerp( - descriptionStyleFeatured, - other.descriptionStyleFeatured, - t, - )!, - featuredBorderColor: Color.lerp( - featuredBorderColor, - other.featuredBorderColor, - t, - )!, - disabledLabelColor: Color.lerp( - disabledLabelColor, - other.disabledLabelColor, - t, - )!, - disabledTextStyle: TextStyle.lerp( - disabledTextStyle, - other.disabledTextStyle, - t, - )!, - iconSize: lerpDouble(iconSize, other.iconSize, t)!, - iconSizeCompact: lerpDouble(iconSizeCompact, other.iconSizeCompact, t)!, - borderRadius: lerpDouble(borderRadius, other.borderRadius, t)!, - padding: EdgeInsetsGeometry.lerp(padding, other.padding, t)!, - compactPadding: EdgeInsetsGeometry.lerp( - compactPadding, - other.compactPadding, - t, - )!, - heightThreshold: lerpDouble(heightThreshold, other.heightThreshold, t)!, - ); - } -} diff --git a/lib/core/themes/extensions/metric_card_theme.dart b/lib/core/themes/extensions/metric_card_theme.dart deleted file mode 100644 index 6f2d660..0000000 --- a/lib/core/themes/extensions/metric_card_theme.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; - -class MetricCardTheme extends ThemeExtension { - final Color backgroundColor; - final Color borderColor; - final double borderRadius; - final EdgeInsetsGeometry padding; - - final TextStyle labelStyle; - final TextStyle valueStyle; - final TextStyle valueUnitStyle; - final TextStyle captionStyle; - final TextStyle variationTextStyle; - - final Color variationPositiveColor; - final Color variationNegativeColor; - final Color variationNeutralColor; - - final double sparklineStrokeWidth; - final Color skeletonColor; - - const MetricCardTheme({ - required this.backgroundColor, - required this.borderColor, - required this.labelStyle, - required this.valueStyle, - required this.valueUnitStyle, - required this.captionStyle, - required this.variationTextStyle, - required this.variationPositiveColor, - required this.variationNegativeColor, - required this.variationNeutralColor, - required this.skeletonColor, - this.borderRadius = 16, - this.padding = const EdgeInsets.all(16), - this.sparklineStrokeWidth = 1.6, - }); - - @override - MetricCardTheme copyWith({ - Color? backgroundColor, - Color? borderColor, - double? borderRadius, - EdgeInsetsGeometry? padding, - TextStyle? labelStyle, - TextStyle? valueStyle, - TextStyle? valueUnitStyle, - TextStyle? captionStyle, - TextStyle? variationTextStyle, - Color? variationPositiveColor, - Color? variationNegativeColor, - Color? variationNeutralColor, - Color? skeletonColor, - double? sparklineStrokeWidth, - }) { - return MetricCardTheme( - backgroundColor: backgroundColor ?? this.backgroundColor, - borderColor: borderColor ?? this.borderColor, - borderRadius: borderRadius ?? this.borderRadius, - padding: padding ?? this.padding, - labelStyle: labelStyle ?? this.labelStyle, - valueStyle: valueStyle ?? this.valueStyle, - valueUnitStyle: valueUnitStyle ?? this.valueUnitStyle, - captionStyle: captionStyle ?? this.captionStyle, - variationTextStyle: variationTextStyle ?? this.variationTextStyle, - variationPositiveColor: - variationPositiveColor ?? this.variationPositiveColor, - variationNegativeColor: - variationNegativeColor ?? this.variationNegativeColor, - variationNeutralColor: - variationNeutralColor ?? this.variationNeutralColor, - skeletonColor: skeletonColor ?? this.skeletonColor, - sparklineStrokeWidth: sparklineStrokeWidth ?? this.sparklineStrokeWidth, - ); - } - - @override - MetricCardTheme lerp( - ThemeExtension? other, - double t, - ) { - if (other is! MetricCardTheme) return this; - return MetricCardTheme( - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - borderColor: Color.lerp(borderColor, other.borderColor, t)!, - borderRadius: lerpDouble(borderRadius, other.borderRadius, t)!, - padding: EdgeInsetsGeometry.lerp(padding, other.padding, t)!, - labelStyle: TextStyle.lerp(labelStyle, other.labelStyle, t)!, - valueStyle: TextStyle.lerp(valueStyle, other.valueStyle, t)!, - valueUnitStyle: TextStyle.lerp(valueUnitStyle, other.valueUnitStyle, t)!, - captionStyle: TextStyle.lerp(captionStyle, other.captionStyle, t)!, - variationTextStyle: - TextStyle.lerp(variationTextStyle, other.variationTextStyle, t)!, - variationPositiveColor: Color.lerp( - variationPositiveColor, other.variationPositiveColor, t)!, - variationNegativeColor: Color.lerp( - variationNegativeColor, other.variationNegativeColor, t)!, - variationNeutralColor: Color.lerp( - variationNeutralColor, other.variationNeutralColor, t)!, - skeletonColor: Color.lerp(skeletonColor, other.skeletonColor, t)!, - sparklineStrokeWidth: - lerpDouble(sparklineStrokeWidth, other.sparklineStrokeWidth, t)!, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/options_bottom_forms_theme.dart b/lib/core/themes/extensions/options_bottom_forms_theme.dart deleted file mode 100644 index 657c5ea..0000000 --- a/lib/core/themes/extensions/options_bottom_forms_theme.dart +++ /dev/null @@ -1,51 +0,0 @@ -// core/themes/theme_extensions/options_bottom_forms_theme.dart -import 'package:flutter/material.dart'; - -class OptionsBottomFormsTheme extends ThemeExtension { - final Color calculateButtonBackground; - final Color calculateButtonForeground; - final Color actionButtonBackground; - final Color actionButtonForeground; - final Color borderButtonColor; - - const OptionsBottomFormsTheme({ - required this.calculateButtonBackground, - required this.calculateButtonForeground, - required this.actionButtonBackground, - required this.actionButtonForeground, - required this.borderButtonColor, - }); - - @override - ThemeExtension copyWith({ - Color? calculateButtonBackground, - Color? calculateButtonForeground, - Color? actionButtonBackground, - Color? actionButtonForeground, - Color? borderButtonColor - }) { - return OptionsBottomFormsTheme( - calculateButtonBackground: calculateButtonBackground ?? this.calculateButtonBackground, - calculateButtonForeground: calculateButtonForeground ?? this.calculateButtonForeground, - actionButtonBackground: actionButtonBackground ?? this.actionButtonBackground, - actionButtonForeground: actionButtonForeground ?? this.actionButtonForeground, - borderButtonColor: borderButtonColor ?? this.borderButtonColor, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! OptionsBottomFormsTheme) return this; - return OptionsBottomFormsTheme( - calculateButtonBackground: Color.lerp(calculateButtonBackground, other.calculateButtonBackground, t)!, - calculateButtonForeground: Color.lerp(calculateButtonForeground, other.calculateButtonForeground, t)!, - actionButtonBackground: Color.lerp(actionButtonBackground, other.actionButtonBackground, t)!, - actionButtonForeground: Color.lerp(actionButtonForeground, other.actionButtonForeground, t)!, - borderButtonColor: Color.lerp(borderButtonColor, other.borderButtonColor, t)!, - - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/page_header_theme.dart b/lib/core/themes/extensions/page_header_theme.dart deleted file mode 100644 index 3988921..0000000 --- a/lib/core/themes/extensions/page_header_theme.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; - -class PageHeaderTheme extends ThemeExtension { - final Color backgroundColor; - final Color iconColor; - final TextStyle titleStyle; - - const PageHeaderTheme({ - required this.backgroundColor, - required this.iconColor, - required this.titleStyle, - }); - - @override - PageHeaderTheme copyWith({ - Color? backgroundColor, - Color? iconColor, - TextStyle? titleStyle, - }) { - return PageHeaderTheme( - backgroundColor: backgroundColor ?? this.backgroundColor, - iconColor: iconColor ?? this.iconColor, - titleStyle: titleStyle ?? this.titleStyle, - ); - } - - @override - PageHeaderTheme lerp(ThemeExtension? other, double t) { - if (other is! PageHeaderTheme) return this; - return PageHeaderTheme( - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - iconColor: Color.lerp(iconColor, other.iconColor, t)!, - titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t)!, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/title_widget_theme.dart b/lib/core/themes/extensions/title_widget_theme.dart deleted file mode 100644 index 46ea7d1..0000000 --- a/lib/core/themes/extensions/title_widget_theme.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -class TitleWidgetTheme extends ThemeExtension { - final TextStyle titleStyle; - final TextStyle subtitleStyle; - - const TitleWidgetTheme({ - required this.titleStyle, - required this.subtitleStyle, - }); - - @override - ThemeExtension copyWith({ - TextStyle? titleStyle, - TextStyle? subtitleStyle, - }) { - return TitleWidgetTheme( - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! TitleWidgetTheme) return this; - return TitleWidgetTheme( - titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t)!, - subtitleStyle: TextStyle.lerp(subtitleStyle, other.subtitleStyle, t)!, - ); - } -} \ No newline at end of file diff --git a/lib/core/themes/extensions/value_correction_result_theme.dart b/lib/core/themes/extensions/value_correction_result_theme.dart deleted file mode 100644 index 4010348..0000000 --- a/lib/core/themes/extensions/value_correction_result_theme.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; - -class ValueCorrectionResultTheme extends ThemeExtension { - final Color cardBackgroundColor; - final Color cardBorderColor; - final Color spotlightBackground; - final Color spotlightBorderColor; - final Color variationPositiveColor; - final Color shareButtonColor; - final Color editButtonColor; - final TextStyle titleStyle; - final TextStyle subtitleStyle; - final TextStyle spotlightLabelStyle; - final TextStyle spotlightValueStyle; - final TextStyle spotlightVariationStyle; - final TextStyle detailLabelStyle; - final TextStyle detailValueStyle; - final TextStyle linkStyle; - - const ValueCorrectionResultTheme({ - required this.cardBackgroundColor, - required this.cardBorderColor, - required this.spotlightBackground, - required this.spotlightBorderColor, - required this.variationPositiveColor, - required this.shareButtonColor, - required this.editButtonColor, - required this.titleStyle, - required this.subtitleStyle, - required this.spotlightLabelStyle, - required this.spotlightValueStyle, - required this.spotlightVariationStyle, - required this.detailLabelStyle, - required this.detailValueStyle, - required this.linkStyle, - }); - - @override - ThemeExtension copyWith({ - Color? cardBackgroundColor, - Color? cardBorderColor, - Color? spotlightBackground, - Color? spotlightBorderColor, - Color? variationPositiveColor, - Color? shareButtonColor, - Color? editButtonColor, - TextStyle? titleStyle, - TextStyle? subtitleStyle, - TextStyle? spotlightLabelStyle, - TextStyle? spotlightValueStyle, - TextStyle? spotlightVariationStyle, - TextStyle? detailLabelStyle, - TextStyle? detailValueStyle, - TextStyle? linkStyle, - }) { - return ValueCorrectionResultTheme( - cardBackgroundColor: cardBackgroundColor ?? this.cardBackgroundColor, - cardBorderColor: cardBorderColor ?? this.cardBorderColor, - spotlightBackground: spotlightBackground ?? this.spotlightBackground, - spotlightBorderColor: spotlightBorderColor ?? this.spotlightBorderColor, - variationPositiveColor: - variationPositiveColor ?? this.variationPositiveColor, - shareButtonColor: shareButtonColor ?? this.shareButtonColor, - editButtonColor: editButtonColor ?? this.editButtonColor, - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - spotlightLabelStyle: spotlightLabelStyle ?? this.spotlightLabelStyle, - spotlightValueStyle: spotlightValueStyle ?? this.spotlightValueStyle, - spotlightVariationStyle: - spotlightVariationStyle ?? this.spotlightVariationStyle, - detailLabelStyle: detailLabelStyle ?? this.detailLabelStyle, - detailValueStyle: detailValueStyle ?? this.detailValueStyle, - linkStyle: linkStyle ?? this.linkStyle, - ); - } - - @override - ThemeExtension lerp( - ThemeExtension? other, - double t, - ) { - if (other is! ValueCorrectionResultTheme) return this; - - return ValueCorrectionResultTheme( - cardBackgroundColor: Color.lerp(cardBackgroundColor, other.cardBackgroundColor, t)!, - cardBorderColor: Color.lerp(cardBorderColor, other.cardBorderColor, t)!, - spotlightBackground: Color.lerp(spotlightBackground, other.spotlightBackground, t)!, - spotlightBorderColor: Color.lerp(spotlightBorderColor, other.spotlightBorderColor, t)!, - variationPositiveColor: Color.lerp(variationPositiveColor, other.variationPositiveColor, t)!, - shareButtonColor: Color.lerp(shareButtonColor, other.shareButtonColor, t)!, - editButtonColor: Color.lerp(editButtonColor, other.editButtonColor, t)!, - titleStyle: TextStyle.lerp(titleStyle, other.titleStyle, t)!, - subtitleStyle: TextStyle.lerp(subtitleStyle, other.subtitleStyle, t)!, - spotlightLabelStyle: TextStyle.lerp(spotlightLabelStyle, other.spotlightLabelStyle, t)!, - spotlightValueStyle: TextStyle.lerp(spotlightValueStyle, other.spotlightValueStyle, t)!, - spotlightVariationStyle: TextStyle.lerp(spotlightVariationStyle, other.spotlightVariationStyle, t)!, - detailLabelStyle: TextStyle.lerp(detailLabelStyle, other.detailLabelStyle, t)!, - detailValueStyle: TextStyle.lerp(detailValueStyle, other.detailValueStyle, t)!, - linkStyle: TextStyle.lerp(linkStyle, other.linkStyle, t)!, - ); - } -} diff --git a/lib/core/themes/extensions/warning_banner_theme.dart b/lib/core/themes/extensions/warning_banner_theme.dart deleted file mode 100644 index 99741a4..0000000 --- a/lib/core/themes/extensions/warning_banner_theme.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; - -class WarningBannerTheme extends ThemeExtension { - final Color backgroundColor; - final Color borderColor; - final Color iconColor; - final TextStyle textStyle; - final EdgeInsetsGeometry padding; - final double borderRadius; - final double iconSize; - final double iconTextSpacing; - - const WarningBannerTheme({ - required this.backgroundColor, - required this.borderColor, - required this.iconColor, - required this.textStyle, - this.padding = const EdgeInsets.all(12), - this.borderRadius = 12, - this.iconSize = 18, - this.iconTextSpacing = 10, - }); - - @override - WarningBannerTheme copyWith({ - Color? backgroundColor, - Color? borderColor, - Color? iconColor, - TextStyle? textStyle, - EdgeInsetsGeometry? padding, - double? borderRadius, - double? iconSize, - double? iconTextSpacing, - }) { - return WarningBannerTheme( - backgroundColor: backgroundColor ?? this.backgroundColor, - borderColor: borderColor ?? this.borderColor, - iconColor: iconColor ?? this.iconColor, - textStyle: textStyle ?? this.textStyle, - padding: padding ?? this.padding, - borderRadius: borderRadius ?? this.borderRadius, - iconSize: iconSize ?? this.iconSize, - iconTextSpacing: iconTextSpacing ?? this.iconTextSpacing, - ); - } - - @override - WarningBannerTheme lerp( - covariant ThemeExtension? other, - double t, - ) { - if (other is! WarningBannerTheme) return this; - - return WarningBannerTheme( - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, - borderColor: Color.lerp(borderColor, other.borderColor, t)!, - iconColor: Color.lerp(iconColor, other.iconColor, t)!, - textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, - padding: EdgeInsetsGeometry.lerp(padding, other.padding, t)!, - borderRadius: borderRadius + (other.borderRadius - borderRadius) * t, - iconSize: iconSize + (other.iconSize - iconSize) * t, - iconTextSpacing: - iconTextSpacing + (other.iconTextSpacing - iconTextSpacing) * t, - ); - } -} diff --git a/lib/core/themes/radius.dart b/lib/core/themes/radius.dart new file mode 100644 index 0000000..ef114dd --- /dev/null +++ b/lib/core/themes/radius.dart @@ -0,0 +1,7 @@ +abstract class AppRadius { + static const double button = 28; + static const double sheet = 28; + static const double iconTile = 12; + static const double search = 14; + static const double pill = 999; +} \ No newline at end of file diff --git a/lib/core/themes/spacing.dart b/lib/core/themes/spacing.dart index 9fee8bd..38c5d0c 100644 --- a/lib/core/themes/spacing.dart +++ b/lib/core/themes/spacing.dart @@ -7,6 +7,13 @@ abstract class AppSpacing { static const double lg = 24; static const double xl = 32; static const double xxl = 48; + + // Vibrante minimalista layout tokens. + static const double screen = 24; + static const double rowHeight = 58; + static const double rowHeightWithSecondLine = 64; + static const double homeRowHeight = 72; + static const double sectionGap = 26; // Padding padrão de tela static const EdgeInsets screenPadding = EdgeInsets.all(md); diff --git a/lib/core/themes/text_styles.dart b/lib/core/themes/text_styles.dart index 87433e2..abef221 100644 --- a/lib/core/themes/text_styles.dart +++ b/lib/core/themes/text_styles.dart @@ -1,120 +1,123 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'color_tokens.dart'; class AppTextStyles { - static final TextStyle _base = GoogleFonts.manrope(); - static final TextStyle _baseWhite = _base.copyWith(color: Colors.white); + static const String manropeFamily = 'Manrope'; + static const String bricolageGrotesqueFamily = 'Bricolage Grotesque'; - // Headlines — mantidos, ainda usados pela TextTheme padrão do Material - // (dialogs, snackbars etc.) mesmo com o header customizado nas telas. - static final TextStyle headlineLarge = _base.copyWith( - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, + // Estilos de destaque e componentes da direção Vibrante minimalista. + // As cores são definidas pelo componente ou tema que utiliza cada estilo. + static final TextStyle bandStatement = const TextStyle( + fontFamily: bricolageGrotesqueFamily, + fontSize: 48, + fontWeight: FontWeight.w800, + letterSpacing: -1.8, + ); + static final TextStyle screenTitle = const TextStyle( + fontFamily: bricolageGrotesqueFamily, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.9, + ); + static final TextStyle sheetTitle = const TextStyle( + fontFamily: bricolageGrotesqueFamily, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + ); + static final TextStyle exampleValue = const TextStyle( + fontFamily: bricolageGrotesqueFamily, + fontSize: 30, + fontWeight: FontWeight.w800, + letterSpacing: -0.8, ); - static final TextStyle headlineMedium = _baseWhite.copyWith( - fontSize: 18, - fontWeight: FontWeight.bold, + static final TextStyle wordmark = const TextStyle( + fontFamily: bricolageGrotesqueFamily, + fontSize: 21, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ); - // Body — mantidos sem alteração, `bodyLarge` já é o tamanho correto - // usado nos valores de input pelo InputForms. - static final TextStyle bodyLarge = _base.copyWith(fontSize: 16); - static final TextStyle bodyMedium = _base.copyWith(fontSize: 14); - static final TextStyle labelLarge = _base.copyWith( - fontSize: 14, + static final TextStyle bandKicker = const TextStyle( + fontFamily: manropeFamily, + fontSize: 15, + fontWeight: FontWeight.w600, + ); + static final TextStyle bandSub = const TextStyle( + fontFamily: manropeFamily, + fontSize: 15, fontWeight: FontWeight.w500, - letterSpacing: 0.5, ); - - // Componentes gerais — mantidos. errorBannerText já usa ColorTokens.error, - // só depende da cor em si ser atualizada (ColorTokens), não do estilo. - static final TextStyle helpCardMessage = _base.copyWith( - fontSize: 14, - color: ColorTokens.textPrimary, - fontWeight: FontWeight.w400, - ); - static final TextStyle inputHelperText = _base.copyWith( - fontSize: 12, - color: ColorTokens.textHint, + static final TextStyle headerTitle = const TextStyle( + fontFamily: manropeFamily, + fontSize: 16, + fontWeight: FontWeight.w700, ); - static final TextStyle errorBannerText = _base.copyWith( - fontSize: 13, + static final TextStyle rowLabel = const TextStyle( + fontFamily: manropeFamily, + fontSize: 16, fontWeight: FontWeight.w500, - color: ColorTokens.error, ); - - // Card de métrica (Selic) — NOVO. Substitui highlightCardTitle/ - // highlightCardSubtitle/highlightCardButton, que eram específicos do - // HighlightCard antigo (removido). Cor não é fixada aqui porque muda - // conforme o estado (amber padrão, verde/vermelho em variação futura); - // aplicar via .copyWith(color: ...) no componente. - static final TextStyle metricLabel = _base.copyWith( - fontSize: 12, - color: ColorTokens.textSecondary, + static final TextStyle rowValue = TextStyle( + fontFamily: manropeFamily, + fontSize: 17, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], ); - static final TextStyle metricValue = _base.copyWith( - fontSize: 30, + static final TextStyle rowUnit = const TextStyle( + fontFamily: manropeFamily, + fontSize: 14, fontWeight: FontWeight.w500, - letterSpacing: -0.6, - fontFeatures: const [FontFeature.tabularFigures()], ); - static final TextStyle metricCaption = _base.copyWith( - fontSize: 11, - color: ColorTokens.textHint, + static final TextStyle rowState = const TextStyle( + fontFamily: manropeFamily, + fontSize: 12, + fontWeight: FontWeight.w700, ); - - // Badge de estado do campo ("vazio" / "calculado" / "resultado") — NOVO. - // Sem cor fixa: cada estado (amber/verde/vermelho) aplica a própria cor. - static final TextStyle stateBadge = _base.copyWith( - fontSize: 11, + static final TextStyle listTitle = const TextStyle( + fontFamily: manropeFamily, + fontSize: 17, + fontWeight: FontWeight.w700, + ); + static final TextStyle listDesc = const TextStyle( + fontFamily: manropeFamily, + fontSize: 13, fontWeight: FontWeight.w500, ); - - // MenuCard - static final TextStyle menuCardTitle = _baseWhite.copyWith( + static final TextStyle sectionLabel = const TextStyle( + fontFamily: manropeFamily, fontSize: 13, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ); - static final TextStyle menuCardTitleCompact = _baseWhite.copyWith( - fontSize: 11, - fontWeight: FontWeight.bold, + static final TextStyle detailLabel = const TextStyle( + fontFamily: manropeFamily, + fontSize: 14, + fontWeight: FontWeight.w500, ); - static final TextStyle menuCardDescription = _baseWhite.copyWith( - fontSize: 10, - height: 1.1, + static final TextStyle detailValue = TextStyle( + fontFamily: manropeFamily, + fontSize: 15, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], ); - // Corrigido: estava em 9px, abaixo do mínimo legível (11px). - static final TextStyle menuCardDescriptionCompact = _baseWhite.copyWith( - fontSize: 11, - height: 1.1, + static final TextStyle button = const TextStyle( + fontFamily: manropeFamily, + fontSize: 16, + fontWeight: FontWeight.w800, ); - - // MenuCard — variante featured (tile full-width, texto um pouco maior - // por ter mais espaço horizontal disponível). NOVO. - static final TextStyle menuCardTitleFeatured = _baseWhite.copyWith( - fontSize: 14, - fontWeight: FontWeight.bold, + static final TextStyle pill = const TextStyle( + fontFamily: manropeFamily, + fontSize: 13, + fontWeight: FontWeight.w800, ); - static final TextStyle menuCardDescriptionFeatured = _base.copyWith( - fontSize: 12, - color: ColorTokens.textSecondary, + static final TextStyle body = const TextStyle( + fontFamily: manropeFamily, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.5, ); - - // Home - static final TextStyle appName = _baseWhite.copyWith( - fontSize: 20, - fontWeight: FontWeight.bold, - ); - // Cor movida pra dentro do estilo (era aplicada com .withValues(alpha: 0.4) - // no call site em home_page.dart) — evita repetir o ajuste de opacidade - // toda vez que o label for usado. - static final TextStyle sectionHeader = _base.copyWith( + static final TextStyle caption = const TextStyle( + fontFamily: manropeFamily, fontSize: 12, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: ColorTokens.textSecondary, + fontWeight: FontWeight.w500, ); - static final TextStyle historyItem = _baseWhite.copyWith(fontSize: 14); -} \ No newline at end of file +} diff --git a/lib/core/themes/theme_cubit.dart b/lib/core/themes/theme_cubit.dart new file mode 100644 index 0000000..83d30a9 --- /dev/null +++ b/lib/core/themes/theme_cubit.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Guarda e persiste o [ThemeMode] escolhido pelo usuário. +class ThemeCubit extends Cubit { + static const storageKey = 'theme_mode'; + + static const initialMode = ThemeMode.system; + + final SharedPreferences _preferences; + + ThemeCubit(this._preferences) : super(_read(_preferences)); + + static ThemeMode _read(SharedPreferences preferences) { + final stored = preferences.getString(storageKey); + return ThemeMode.values.firstWhere( + (mode) => mode.name == stored, + orElse: () => initialMode, + ); + } + + Future setMode(ThemeMode mode) async { + emit(mode); + await _preferences.setString(storageKey, mode.name); + } + + /// Alterna entre claro e escuro a partir do brilho que está na tela. + Future toggle(Brightness current) => + setMode(current == Brightness.dark ? ThemeMode.light : ThemeMode.dark); +} diff --git a/lib/core/utils/br_number_format.dart b/lib/core/utils/br_number_format.dart new file mode 100644 index 0000000..c17809e --- /dev/null +++ b/lib/core/utils/br_number_format.dart @@ -0,0 +1,24 @@ +import 'dart:math'; + +import 'package:intl/intl.dart'; + +final _decimal = NumberFormat('#,##0.00', 'pt_BR'); + +/// Arredonda para centavos e devolve o valor em centavos inteiros, para as +/// contas derivadas fecharem com o que a pessoa vê na tela. +int toCents(double value) => (value * 100).round(); + +/// "R$ 2.836,80", com espaço não separável entre o símbolo e o número. +/// Negativos ficam com o sinal antes do símbolo: "-R$ 10,00". +String formatCents(int cents) { + final sign = cents < 0 ? '-' : ''; + return '${sign}R\$\u00A0${_decimal.format(cents.abs() / 100)}'; +} + +/// "26,82%". +String formatPercent(double percent) => '${_decimal.format(percent)}%'; + +/// Taxa anual equivalente, em %, a partir da taxa mensal em %: +/// (1 + i)¹² − 1. +double annualEquivalentRate(double monthlyPercent) => + (pow(1 + monthlyPercent / 100, 12) - 1) * 100; diff --git a/lib/core/utils/share_text.dart b/lib/core/utils/share_text.dart new file mode 100644 index 0000000..5f09a32 --- /dev/null +++ b/lib/core/utils/share_text.dart @@ -0,0 +1,24 @@ +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +const shareDisclaimer = 'Simulação feita no RealCalc, sem valor oficial.'; + +String shareText({ + required String calculator, + required BandStatement statement, + required List<(String, String)> lines, +}) { + final headline = [ + ?statement.kicker, + statement.isMoney ? 'R\$ ${statement.value}' : statement.value, + ].join(' '); + + return [ + 'RealCalc · $calculator', + headline, + ?statement.sub, + '', + for (final (label, value) in lines) '$label: $value', + '', + shareDisclaimer, + ].join('\n').replaceAll(String.fromCharCode(0xA0), ' '); +} diff --git a/lib/core/widgets/app_icon.dart b/lib/core/widgets/app_icon.dart new file mode 100644 index 0000000..30ef6fc --- /dev/null +++ b/lib/core/widgets/app_icon.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +abstract class AppIcons { + static const _dir = 'assets/icons'; + + static const alert = '$_dir/alert.svg'; + static const arrowDown = '$_dir/arrow_down.svg'; + static const arrowUp = '$_dir/arrow_up.svg'; + static const back = '$_dir/back.svg'; + static const bank = '$_dir/bank.svg'; + static const check = '$_dir/check.svg'; + static const chevronDown = '$_dir/chevron_down.svg'; + static const chevronRight = '$_dir/chevron_right.svg'; + static const close = '$_dir/close.svg'; + static const cycle = '$_dir/cycle.svg'; + static const help = '$_dir/help.svg'; + static const moon = '$_dir/moon.svg'; + static const piggyBank = '$_dir/piggy_bank.svg'; + static const reset = '$_dir/reset.svg'; + static const search = '$_dir/search.svg'; + static const share = '$_dir/share.svg'; + static const sun = '$_dir/sun.svg'; + static const trendingUp = '$_dir/trending_up.svg'; + static const wifiOff = '$_dir/wifi_off.svg'; +} + +/// Ícone de `assets/icons`; sem cor ou tamanho, segue o [IconTheme], como o +/// [Icon], e por isso funciona dentro de [IconButton]. +class AppIcon extends StatelessWidget { + final String asset; + final double? size; + final Color? color; + + const AppIcon(this.asset, {super.key, this.size, this.color}); + + @override + Widget build(BuildContext context) { + final theme = IconTheme.of(context); + final size = this.size ?? theme.size ?? 24; + final color = this.color ?? theme.color ?? Colors.black; + + // Como o Icon: em restrições justas, fica centralizado no tamanho pedido. + return Center( + widthFactor: 1, + heightFactor: 1, + child: SvgPicture.asset( + asset, + width: size, + height: size, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), + ); + } +} diff --git a/lib/core/widgets/app_logo.dart b/lib/core/widgets/app_logo.dart new file mode 100644 index 0000000..4042657 --- /dev/null +++ b/lib/core/widgets/app_logo.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_image.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +class AppLogo extends StatelessWidget { + static const double smallSymbolMaxHeight = 24; + + final double symbolHeight; + final bool showWordmark; + + const AppLogo({super.key, this.symbolHeight = 26, this.showWordmark = true}); + + @override + Widget build(BuildContext context) { + final amber = AppColors.of(context).brandAmber; + final symbol = SvgPicture.asset( + symbolHeight <= smallSymbolMaxHeight + ? AppImage.appLogoSmall + : AppImage.appLogo, + height: symbolHeight, + colorFilter: ColorFilter.mode(amber, BlendMode.srcIn), + ); + + return Semantics( + label: 'RealCalc', + image: true, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + symbol, + if (showWordmark) ...[ + const SizedBox(width: 10), + Text.rich( + TextSpan( + children: [ + const TextSpan( + text: 'Real', + style: TextStyle(color: Colors.white), + ), + TextSpan( + text: 'Calc', + style: TextStyle(color: amber), + ), + ], + ), + style: AppTextStyles.wordmark, + ), + ], + ], + ), + ); + } +} diff --git a/lib/core/widgets/app_sheet.dart b/lib/core/widgets/app_sheet.dart new file mode 100644 index 0000000..263cc80 --- /dev/null +++ b/lib/core/widgets/app_sheet.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/radius.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +Future showAppSheet({ + required BuildContext context, + required String title, + required WidgetBuilder bodyBuilder, + WidgetBuilder? footerBuilder, + CalculatorKind? kind, + bool scrollable = true, +}) { + final colors = AppColors.of(context); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: colors.sheet, + barrierColor: colors.scrim, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppRadius.sheet), + ), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - AppSheet.topGap, + ), + builder: (context) { + final sheet = AppSheet( + title: title, + body: Builder(builder: bodyBuilder), + footer: footerBuilder == null ? null : Builder(builder: footerBuilder), + scrollable: scrollable, + ); + return kind == null ? sheet : CalculatorScope(kind: kind, child: sheet); + }, + ); +} + +class AppSheet extends StatelessWidget { + static const double topGap = 96; + static const closeLabel = 'Fechar'; + + final String title; + final Widget body; + final Widget? footer; + + /// Com `false`, o corpo cuida da própria rolagem e do respiro inferior. + final bool scrollable; + + const AppSheet({ + super.key, + required this.title, + required this.body, + this.footer, + this.scrollable = true, + }); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final keyboard = MediaQuery.viewInsetsOf(context).bottom; + + final sheet = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 10), + child: Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.textHint.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.screen, 8, 12, 8), + child: Row( + children: [ + Expanded( + child: Semantics( + header: true, + child: Text( + title, + style: AppTextStyles.sheetTitle.copyWith( + color: colors.textPrimary, + ), + ), + ), + ), + MergeSemantics( + child: Semantics( + label: closeLabel, + child: SizedBox.square( + dimension: 44, + child: IconButton( + onPressed: () => Navigator.of(context).maybePop(), + padding: EdgeInsets.zero, + color: colors.textSecondary, + icon: const AppIcon(AppIcons.close, size: 22), + ), + ), + ), + ), + ], + ), + ), + Flexible( + child: scrollable + ? SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + footer == null + ? AppSpacing.lg + bottomInset + : AppSpacing.md, + ), + child: body, + ) + : body, + ), + if (footer != null) + Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + AppSpacing.md + bottomInset, + ), + child: footer, + ), + ], + ); + + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: sheet, + ); + } +} diff --git a/lib/core/widgets/bottom_action_bar.dart b/lib/core/widgets/bottom_action_bar.dart new file mode 100644 index 0000000..1e9c510 --- /dev/null +++ b/lib/core/widgets/bottom_action_bar.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +/// Deve ficar no `body`, abaixo da rolagem, e não em `bottomNavigationBar`, +/// para subir junto com o teclado. +class BottomActionBar extends StatelessWidget { + static const double buttonHeight = 56; + static const EdgeInsets padding = EdgeInsets.fromLTRB(24, 12, 24, 30); + static const shareLabel = 'Compartilhar resultado'; + + final String primaryLabel; + final VoidCallback? onPrimary; + final bool isLoading; + + final ValueChanged? onShare; + + const BottomActionBar({ + super.key, + required this.primaryLabel, + required this.onPrimary, + this.onShare, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final scheme = Theme.of(context).colorScheme; + final palette = CalculatorScope.maybeOf(context); + final solid = palette?.solid ?? scheme.primary; + final onSolid = palette?.onSolid ?? scheme.onPrimary; + + return ColoredBox( + color: colors.background, + child: SafeArea( + top: false, + minimum: padding, + child: Row( + children: [ + Opacity( + opacity: onShare == null ? 0.4 : 1, + child: SizedBox.square( + dimension: buttonHeight, + child: Tooltip( + message: shareLabel, + excludeFromSemantics: true, + child: Semantics( + label: shareLabel, + button: true, + enabled: onShare != null, + child: Builder( + builder: (buttonContext) => OutlinedButton( + onPressed: onShare == null + ? null + : () => onShare!(_globalRect(buttonContext)), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.zero, + shape: const CircleBorder(), + side: BorderSide(color: colors.hairline, width: 1.5), + foregroundColor: colors.textPrimary, + disabledForegroundColor: colors.textPrimary, + ), + child: const AppIcon(AppIcons.share, size: 22), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: buttonHeight, + child: FilledButton( + onPressed: isLoading ? null : onPrimary, + style: FilledButton.styleFrom( + backgroundColor: solid, + foregroundColor: onSolid, + disabledBackgroundColor: isLoading + ? solid + : solid.withValues(alpha: 0.4), + disabledForegroundColor: onSolid, + shape: const StadiumBorder(), + textStyle: AppTextStyles.button, + ), + child: isLoading + ? Semantics( + label: primaryLabel, + child: SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: onSolid, + ), + ), + ) + : Text(primaryLabel), + ), + ), + ), + ], + ), + ), + ); + } +} + +ScaffoldFeatureController +showFieldsClearedSnackBar( + BuildContext context, { + required VoidCallback onUndo, +}) { + final messenger = ScaffoldMessenger.of(context)..hideCurrentSnackBar(); + return messenger.showSnackBar( + SnackBar( + content: const Text('Campos limpos'), + duration: const Duration(seconds: 4), + // Com ação, o SnackBar não some sozinho por padrão. + persist: false, + action: SnackBarAction(label: 'Desfazer', onPressed: onUndo), + ), + ); +} + +Rect _globalRect(BuildContext context) { + final box = context.findRenderObject()! as RenderBox; + return box.localToGlobal(Offset.zero) & box.size; +} diff --git a/lib/core/widgets/calculator_band/band_statement.dart b/lib/core/widgets/calculator_band/band_statement.dart new file mode 100644 index 0000000..ca58f84 --- /dev/null +++ b/lib/core/widgets/calculator_band/band_statement.dart @@ -0,0 +1,30 @@ +class BandStatement { + final String? kicker; + + final String value; + + final bool isMoney; + + final String? sub; + + const BandStatement({ + this.kicker, + required this.value, + this.isMoney = false, + this.sub, + }); + + String get semanticsLabel => + [?kicker, isMoney ? 'R\$ $value' : value, ?sub].join(' '); + + @override + bool operator ==(Object other) => + other is BandStatement && + other.kicker == kicker && + other.value == value && + other.isMoney == isMoney && + other.sub == sub; + + @override + int get hashCode => Object.hash(kicker, value, isMoney, sub); +} diff --git a/lib/core/widgets/calculator_band/calculator_band.dart b/lib/core/widgets/calculator_band/calculator_band.dart new file mode 100644 index 0000000..f01bad9 --- /dev/null +++ b/lib/core/widgets/calculator_band/calculator_band.dart @@ -0,0 +1,255 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:flutter/services.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +import 'band_statement.dart'; + +class CalculatorBand extends StatelessWidget { + static const double iconTouchSize = 44; + static const double moneySymbolSize = 22; + static const double minValueSize = 36; + + static const backLabel = 'Voltar'; + static const helpLabel = 'Como funciona'; + static const clearLabel = 'Limpar campos'; + + static const statusBarStyle = SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ); + + final String title; + final BandStatement statement; + final VoidCallback? onBack; + final VoidCallback? onHelp; + final VoidCallback? onClear; + + const CalculatorBand({ + super.key, + required this.title, + required this.statement, + this.onBack, + this.onHelp, + this.onClear, + }); + + @override + Widget build(BuildContext context) { + final palette = + CalculatorScope.maybeOf(context) ?? + CalculatorPalette( + solid: Theme.of(context).colorScheme.primary, + onSolid: Theme.of(context).colorScheme.onPrimary, + text: Theme.of(context).colorScheme.primary, + ); + final onSolid = palette.onSolid; + + return AnnotatedRegion( + value: statusBarStyle, + child: ColoredBox( + color: palette.solid, + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Row( + children: [ + _BandIconButton( + icon: AppIcons.back, + label: backLabel, + color: onSolid, + onPressed: + onBack ?? () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: 2), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.headerTitle.copyWith( + color: onSolid, + ), + ), + ), + if (onHelp != null) + _BandIconButton( + icon: AppIcons.help, + label: helpLabel, + color: onSolid, + onPressed: onHelp!, + ), + if (onClear != null) + _BandIconButton( + icon: AppIcons.reset, + label: clearLabel, + color: onSolid, + onPressed: onClear!, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + AppSpacing.lg + 4, + ), + child: _Statement(statement: statement, color: onSolid), + ), + ], + ), + ), + ), + ); + } +} + +class _Statement extends StatelessWidget { + final BandStatement statement; + final Color color; + + const _Statement({required this.statement, required this.color}); + + @override + Widget build(BuildContext context) { + final soft = color.withValues(alpha: 0.82); + + return Semantics( + container: true, + liveRegion: true, + label: statement.semanticsLabel, + child: ExcludeSemantics( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (statement.kicker != null) + Text( + statement.kicker!, + style: AppTextStyles.bandKicker.copyWith(color: soft), + ), + _StatementValue(statement: statement, color: color), + if (statement.sub != null) + Text( + statement.sub!, + style: AppTextStyles.bandSub.copyWith(color: soft), + ), + ], + ), + ), + ); + } +} + +class _StatementValue extends StatelessWidget { + final BandStatement statement; + final Color color; + + const _StatementValue({required this.statement, required this.color}); + + @override + Widget build(BuildContext context) { + final textScaler = MediaQuery.textScalerOf(context); + final baseStyle = AppTextStyles.bandStatement.copyWith(color: color); + final symbolStyle = baseStyle.copyWith( + fontSize: CalculatorBand.moneySymbolSize, + letterSpacing: -0.4, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final symbolWidth = statement.isMoney + ? _measure('R\$', symbolStyle, textScaler) + 6 + : 0.0; + final available = constraints.maxWidth - symbolWidth; + final naturalWidth = _measure(statement.value, baseStyle, textScaler); + final baseSize = baseStyle.fontSize!; + final fontSize = naturalWidth <= available + ? baseSize + : math.max( + CalculatorBand.minValueSize, + (baseSize * available / naturalWidth).floorToDouble(), + ); + + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + if (statement.isMoney) ...[ + Text('R\$', style: symbolStyle), + const SizedBox(width: 6), + ], + Text( + statement.value, + maxLines: 1, + softWrap: false, + style: baseStyle.copyWith(fontSize: fontSize), + ), + ], + ), + ); + }, + ); + } + + static double _measure(String text, TextStyle style, TextScaler scaler) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + textScaler: scaler, + maxLines: 1, + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } +} + +class _BandIconButton extends StatelessWidget { + final String icon; + final String label; + final Color color; + final VoidCallback onPressed; + + const _BandIconButton({ + required this.icon, + required this.label, + required this.color, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + label: label, + child: SizedBox.square( + dimension: CalculatorBand.iconTouchSize, + child: IconButton( + onPressed: onPressed, + padding: EdgeInsets.zero, + color: color, + icon: AppIcon(icon, size: 22), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/error_banner.dart b/lib/core/widgets/error_banner.dart deleted file mode 100644 index 4a41b7d..0000000 --- a/lib/core/widgets/error_banner.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../themes/extensions/error_banner_theme.dart'; - -class ErrorBanner extends StatelessWidget { - final String message; - final VoidCallback? onRetry; - final String retryLabel; - - const ErrorBanner({ - super.key, - required this.message, - this.onRetry, - this.retryLabel = 'Tentar novamente', - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - return Container( - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all(color: theme.borderColor), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.warning_amber_rounded, - size: theme.iconSize, - color: theme.iconColor, - ), - SizedBox(width: theme.iconMessageSpacing), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(message, style: theme.messageStyle), - if (onRetry != null) ...[ - SizedBox(height: theme.retrySpacing), - TextButton( - onPressed: onRetry, - style: TextButton.styleFrom( - foregroundColor: theme.retryTextColor, - padding: EdgeInsets.zero, - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text(retryLabel), - ), - ], - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/core/widgets/field_list/field_list.dart b/lib/core/widgets/field_list/field_list.dart new file mode 100644 index 0000000..2d81655 --- /dev/null +++ b/lib/core/widgets/field_list/field_list.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; + +/// Lista de campos: uma coluna com linha `hairline` no topo, sem cartão, +/// sombra ou ícones. Cada [FieldRow] desenha a própria linha de baixo. +class FieldList extends StatelessWidget { + final List children; + + const FieldList({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.hairline)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } +} diff --git a/lib/core/widgets/field_list/field_row.dart b/lib/core/widgets/field_list/field_row.dart new file mode 100644 index 0000000..0a4ba54 --- /dev/null +++ b/lib/core/widgets/field_list/field_row.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:flutter/services.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/radius.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +import '../types/input_field_state.dart'; + +/// Linha de um campo da [FieldList]. +/// +/// À esquerda ficam o rótulo e uma segunda linha opcional; à direita, sem +/// quebra, o prefixo, o valor e a unidade. A cor de destaque vem do +/// [CalculatorScope] mais próximo. +/// +/// Estados: +/// - neutro; +/// - em edição (com foco): linha de 2px e cursor na cor da calculadora; +/// - [InputFieldState.unknown]: "a descobrir" e pílula "Toque para calcular"; +/// - [InputFieldState.calculated]: "calculado para você", ✓ e valor em +/// destaque; +/// - [InputFieldState.error]: linha, valor e segunda linha em `error`; +/// - seleção ([FieldRow.selection]): valor e chevron, abre um seletor. +class FieldRow extends StatefulWidget { + static const unknownText = 'a descobrir'; + static const calculatedText = 'calculado para você'; + static const calculateText = 'Toque para calcular'; + + final String label; + + /// Segunda linha opcional, como "todo mês" ou "opcional". Nos estados + /// a descobrir e calculado é trocada pelo texto do estado. + final String? secondLine; + + /// Texto da segunda linha no estado de erro (ex.: "fora do período"). + final String? errorText; + + final InputFieldState state; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? prefix; + final String? unit; + final String hint; + final TextInputType keyboardType; + final List? inputFormatters; + final ValueChanged? onChanged; + + /// Chamado ao tocar na linha no estado a descobrir. + final VoidCallback? onCalculate; + + /// Espaço mantido entre o campo em foco e o teclado. + final EdgeInsets scrollPadding; + + final String? _selectionValue; + final VoidCallback? _onSelect; + + const FieldRow({ + super.key, + required this.label, + required TextEditingController this.controller, + this.secondLine, + this.errorText, + this.state = InputFieldState.neutral, + this.focusNode, + this.prefix, + this.unit, + this.hint = '0', + this.keyboardType = const TextInputType.numberWithOptions(decimal: true), + this.inputFormatters, + this.onChanged, + this.onCalculate, + this.scrollPadding = const EdgeInsets.fromLTRB(20, 20, 20, 120), + }) : _selectionValue = null, + _onSelect = null; + + /// Linha de seleção (ex.: Índice): mostra o valor e um chevron e chama + /// [onTap] ao ser tocada. + const FieldRow.selection({ + super.key, + required this.label, + required String value, + required VoidCallback onTap, + this.secondLine, + this.errorText, + this.state = InputFieldState.neutral, + }) : _selectionValue = value, + _onSelect = onTap, + controller = null, + focusNode = null, + prefix = null, + unit = null, + hint = '', + keyboardType = TextInputType.none, + inputFormatters = null, + onChanged = null, + onCalculate = null, + scrollPadding = EdgeInsets.zero; + + bool get isSelection => _onSelect != null; + + @override + State createState() => _FieldRowState(); +} + +class _FieldRowState extends State { + /// Parte máxima da linha ocupada pelo bloco da direita; o resto fica para + /// o rótulo e a segunda linha, que quebram. + static const _maxTrailingFraction = 0.65; + + FocusNode? _ownFocusNode; + + FocusNode get _focusNode => + widget.focusNode ?? (_ownFocusNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _focusNode.addListener(_rebuild); + widget.controller?.addListener(_rebuild); + } + + @override + void didUpdateWidget(FieldRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + (oldWidget.focusNode ?? _ownFocusNode)?.removeListener(_rebuild); + _focusNode.addListener(_rebuild); + } + if (oldWidget.controller != widget.controller) { + oldWidget.controller?.removeListener(_rebuild); + widget.controller?.addListener(_rebuild); + } + } + + @override + void dispose() { + _focusNode.removeListener(_rebuild); + widget.controller?.removeListener(_rebuild); + _ownFocusNode?.dispose(); + super.dispose(); + } + + void _rebuild() => setState(() {}); + + bool get _isUnknown => widget.state == InputFieldState.unknown; + bool get _isCalculated => widget.state == InputFieldState.calculated; + bool get _isError => widget.state == InputFieldState.error; + bool get _isEditing => + !widget.isSelection && !_isUnknown && _focusNode.hasFocus; + + void _handleTap() { + if (widget.isSelection) { + widget._onSelect!(); + } else if (_isUnknown) { + widget.onCalculate?.call(); + } else { + _focusNode.requestFocus(); + } + } + + CalculatorPalette _palette(BuildContext context) { + final scoped = CalculatorScope.maybeOf(context); + if (scoped != null) return scoped; + final scheme = Theme.of(context).colorScheme; + return CalculatorPalette( + solid: scheme.primary, + onSolid: scheme.onPrimary, + text: scheme.primary, + ); + } + + String? get _stateText { + if (_isError) return widget.errorText ?? widget.secondLine; + if (_isUnknown) return FieldRow.unknownText; + if (_isCalculated) return FieldRow.calculatedText; + return widget.secondLine; + } + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final palette = _palette(context); + final secondText = _stateText; + + final (lineColor, lineWidth) = switch (true) { + _ when _isError => (colors.error, 1.5), + _ when _isUnknown || _isEditing => (palette.solid, 2.0), + _ => (colors.hairline, 1.0), + }; + + final secondStyle = (_isError || _isUnknown || _isCalculated) + ? AppTextStyles.rowState.copyWith( + color: _isError ? colors.error : palette.text, + ) + : AppTextStyles.caption.copyWith(color: colors.textSecondary); + + final row = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + child: Container( + constraints: BoxConstraints( + minHeight: secondText == null + ? AppSpacing.rowHeight + : AppSpacing.rowHeightWithSecondLine, + ), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: lineColor, width: lineWidth), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: ExcludeSemantics( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label, + style: AppTextStyles.rowLabel.copyWith( + color: colors.textPrimary, + ), + ), + if (secondText != null) ...[ + const SizedBox(height: 2), + Text(secondText, style: secondStyle), + ], + ], + ), + ), + ), + const SizedBox(width: AppSpacing.md), + // Valor sem quebra: se não couber (tela estreita e fonte grande), + // diminui inteiro em vez de ser cortado. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * _maxTrailingFraction, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: _buildTrailing(context, colors, palette), + ), + ), + ], + ), + ), + ), + ); + + if (widget.isSelection || _isUnknown) { + final value = widget.isSelection + ? widget._selectionValue + : FieldRow.unknownText; + return Semantics( + container: true, + button: true, + label: [ + widget.label, + value, + if (widget.isSelection && secondText != null) secondText, + ].join(', '), + onTap: _handleTap, + excludeSemantics: true, + child: row, + ); + } + + // O TextField contribui com o valor; o rótulo junta nome, unidade e + // estado, na ordem em que o TalkBack deve ler. + return MergeSemantics( + child: Semantics( + label: [widget.label, ?widget.unit, ?secondText].join(', '), + child: row, + ), + ); + } + + Widget _buildTrailing( + BuildContext context, + AppColors colors, + CalculatorPalette palette, + ) { + if (widget.isSelection) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget._selectionValue!, + style: AppTextStyles.rowValue.copyWith( + color: _isError ? colors.error : colors.textPrimary, + ), + ), + const SizedBox(width: AppSpacing.xs), + AppIcon(AppIcons.chevronRight, size: 20, color: colors.textSecondary), + ], + ); + } + + if (_isUnknown) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: palette.solid, + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + child: Text( + FieldRow.calculateText, + style: AppTextStyles.pill.copyWith(color: palette.onSolid), + ), + ); + } + + final valueStyle = AppTextStyles.rowValue.copyWith( + color: switch (widget.state) { + InputFieldState.error => colors.error, + InputFieldState.calculated => palette.text, + _ => colors.textPrimary, + }, + fontWeight: _isCalculated ? FontWeight.w800 : null, + ); + final unitStyle = AppTextStyles.rowUnit.copyWith( + color: _isError ? colors.error : colors.textSecondary, + ); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + if (_isCalculated) ...[ + AppIcon(AppIcons.check, size: 16, color: palette.text), + const SizedBox(width: 6), + ], + if (widget.prefix != null) ...[ + ExcludeSemantics(child: Text(widget.prefix!, style: unitStyle)), + const SizedBox(width: AppSpacing.xs), + ], + // Largura do conteúdo (ou do hint), para prefixo, valor e unidade + // ficarem colados. + IntrinsicWidth( + child: TextField( + controller: widget.controller, + focusNode: _focusNode, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + onChanged: widget.onChanged, + textAlign: TextAlign.end, + textInputAction: TextInputAction.done, + style: valueStyle, + cursorColor: palette.solid, + scrollPadding: widget.scrollPadding, + decoration: InputDecoration( + hintText: widget.hint, + hintStyle: valueStyle.copyWith(color: colors.textHint), + isCollapsed: true, + filled: false, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + ), + ), + if (widget.unit != null) ...[ + const SizedBox(width: AppSpacing.xs), + ExcludeSemantics(child: Text(widget.unit!, style: unitStyle)), + ], + ], + ); + } +} diff --git a/lib/core/widgets/form_notice.dart b/lib/core/widgets/form_notice.dart new file mode 100644 index 0000000..842c184 --- /dev/null +++ b/lib/core/widgets/form_notice.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +class FormNotice extends StatelessWidget { + final String title; + final String message; + final bool isError; + + const FormNotice({ + super.key, + required this.title, + required this.message, + this.isError = false, + }); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final accent = isError ? colors.error : colors.textSecondary; + + return MergeSemantics( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: AppIcon(AppIcons.alert, size: 20, color: accent), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTextStyles.rowLabel.copyWith( + color: isError ? colors.error : colors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + message, + style: AppTextStyles.body.copyWith( + color: colors.textSecondary, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/help_card.dart b/lib/core/widgets/help_card.dart deleted file mode 100644 index cef5b63..0000000 --- a/lib/core/widgets/help_card.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/help_card_theme.dart'; - -class HelpCard extends StatelessWidget { - final String message; - - const HelpCard({super.key, required this.message}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - return Container( - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all( - color: theme.borderColor.withValues(alpha: 0.4), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: theme.iconBackgroundColor, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.lightbulb, - color: theme.iconColor, - size: theme.iconSize, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(message, style: theme.messageStyle), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/core/widgets/help_sheet/help_content.dart b/lib/core/widgets/help_sheet/help_content.dart new file mode 100644 index 0000000..9f00bda --- /dev/null +++ b/lib/core/widgets/help_sheet/help_content.dart @@ -0,0 +1,131 @@ +import 'package:real_calc/core/themes/calculator_palette.dart'; + +class HelpField { + final String name; + final String description; + + const HelpField(this.name, this.description); +} + +class HelpContent { + final String intro; + final List fields; + final String example; + final String exampleResult; + final String methodology; + final String formula; + + const HelpContent({ + required this.intro, + required this.fields, + required this.example, + required this.exampleResult, + required this.methodology, + required this.formula, + }); + + static HelpContent of(CalculatorKind kind) => switch (kind) { + CalculatorKind.financing => financing, + CalculatorKind.deposits => deposits, + CalculatorKind.futureValue => futureValue, + CalculatorKind.correction => correction, + }; + + static const _rate = HelpField( + 'Taxa de juros', + 'Os juros de cada mês, em porcentagem.', + ); + + static const financing = HelpContent( + intro: + 'Descubra a prestação, o valor financiado, o prazo ou a taxa. ' + 'Preencha três campos e o app calcula o que falta.', + fields: [ + HelpField( + 'Valor financiado', + 'Quanto você vai pegar emprestado, sem os juros.', + ), + HelpField('Prazo', 'Em quantos meses a dívida será paga.'), + _rate, + HelpField('Prestação', 'O valor fixo pago todo mês.'), + ], + example: 'R\$ 2.500,00 a 2% ao mês, em 12 meses, dão uma prestação de', + exampleResult: 'R\$ 236,40', + methodology: + 'Usamos a tabela Price, com prestações iguais. V é o valor ' + 'financiado, i a taxa mensal e n o prazo.', + formula: 'P = V × i ÷ (1 − (1 + i)⁻ⁿ)', + ); + + static const deposits = HelpContent( + intro: + 'Veja quanto junta guardando o mesmo valor todo mês, ou descubra o ' + 'depósito, o prazo ou a taxa para chegar a um objetivo.', + fields: [ + HelpField( + 'Depósito mensal', + 'O valor guardado todo mês, sempre no início do mês.', + ), + HelpField('Prazo', 'Por quantos meses você vai depositar.'), + _rate, + HelpField('Valor final', 'Quanto você terá no fim do prazo.'), + ], + example: 'R\$ 100,00 por mês a 1% ao mês, durante 12 meses, viram', + exampleResult: 'R\$ 1.280,93', + methodology: + 'O depósito é feito no início de cada mês e já rende no próprio ' + 'mês. D é o depósito, i a taxa mensal e n o prazo.', + formula: 'VF = D × (1 + i) × ((1 + i)ⁿ − 1) ÷ i', + ); + + static const futureValue = HelpContent( + intro: + 'Veja quanto um valor aplicado hoje rende com juros compostos, ou ' + 'descubra o valor aplicado, o prazo ou a taxa.', + fields: [ + HelpField('Valor aplicado', 'O valor aplicado hoje, uma única vez.'), + HelpField('Prazo', 'Por quantos meses o dinheiro fica aplicado.'), + _rate, + HelpField( + 'Valor futuro', + 'Quanto o valor aplicado vale no fim do prazo.', + ), + ], + example: 'R\$ 1.000,00 a 1% ao mês, durante 12 meses, viram', + exampleResult: 'R\$ 1.126,83', + methodology: + 'Juros compostos: os juros de cada mês também passam a render. VP ' + 'é o valor aplicado, i a taxa mensal e n o prazo.', + formula: 'VF = VP × (1 + i)ⁿ', + ); + + static const correction = HelpContent( + intro: + 'Atualize um valor pela inflação ou por outro índice entre duas ' + 'datas, com os dados oficiais do Banco Central.', + fields: [ + HelpField( + 'Índice de correção', + 'A série usada na correção, como IPCA, IGP-M ou CDI.', + ), + HelpField('Data inicial', 'De quando o valor parte.'), + HelpField('Data final', 'Até quando o valor é corrigido.'), + HelpField( + 'Percentual', + 'Só no CDI: quanto do CDI aplicar, como 100% ou 110%.', + ), + HelpField( + 'Valor a ser corrigido', + 'O valor na data inicial. Sem ele, mostramos só o fator.', + ), + ], + example: + 'Se o índice subiu 0,5% em um mês e 0,4% no seguinte, ' + 'R\$ 1.000,00 viram', + exampleResult: 'R\$ 1.009,02', + methodology: + 'Multiplicamos as variações de cada período e aplicamos o fator ao ' + 'valor. i₁ … iₙ são as variações do índice no período.', + formula: 'fator = (1 + i₁) × … × (1 + iₙ)', + ); +} diff --git a/lib/core/widgets/help_sheet/help_sheet.dart b/lib/core/widgets/help_sheet/help_sheet.dart new file mode 100644 index 0000000..954d222 --- /dev/null +++ b/lib/core/widgets/help_sheet/help_sheet.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +import '../app_sheet.dart'; +import 'help_content.dart'; +import 'methodology.dart'; + +const helpSheetTitle = 'Como funciona'; + +Future showHelpSheet(BuildContext context, CalculatorKind kind) { + return showAppSheet( + context: context, + title: helpSheetTitle, + kind: kind, + bodyBuilder: (_) => HelpSheetBody( + content: HelpContent.of(kind), + methodology: Methodology.of(kind), + ), + footerBuilder: (context) => const _UnderstoodButton(), + ); +} + +class HelpSheetBody extends StatelessWidget { + final HelpContent content; + final Methodology? methodology; + + const HelpSheetBody({super.key, required this.content, this.methodology}); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final palette = CalculatorScope.of(context); + final body = AppTextStyles.body.copyWith(color: colors.textPrimary); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(content.intro, style: body), + _Section(title: 'O que cada campo significa', colors: colors), + DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.hairline)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final field in content.fields) + _FieldDescription(field: field, colors: colors), + ], + ), + ), + _Section(title: 'Exemplo', colors: colors), + Text(content.example, style: body), + const SizedBox(height: AppSpacing.xs), + Text( + content.exampleResult, + style: AppTextStyles.exampleValue.copyWith(color: palette.text), + ), + _Section(title: 'Como calculamos', colors: colors), + Text( + content.methodology, + style: AppTextStyles.body.copyWith(color: colors.textSecondary), + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: colors.elevated, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + content.formula, + style: AppTextStyles.detailValue.copyWith( + color: colors.textPrimary, + ), + ), + ), + if (methodology case final methodology?) ...[ + _Section(title: Methodology.title, colors: colors), + DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.hairline)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in methodology.items) + _MethodologyItem(item: item, colors: colors), + ], + ), + ), + ], + ], + ); + } +} + +class _MethodologyItem extends StatelessWidget { + final MethodologyItem item; + final AppColors colors; + + const _MethodologyItem({required this.item, required this.colors}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.hairline)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + header: true, + child: Text( + item.title, + style: AppTextStyles.rowLabel.copyWith( + color: colors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 2), + Text( + item.text, + style: AppTextStyles.body.copyWith(color: colors.textSecondary), + ), + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + final String title; + final AppColors colors; + + const _Section({required this.title, required this.colors}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + top: AppSpacing.sectionGap, + bottom: AppSpacing.sm, + ), + child: Semantics( + header: true, + child: Text( + title, + style: AppTextStyles.sectionLabel.copyWith( + color: colors.textSecondary, + ), + ), + ), + ); + } +} + +class _FieldDescription extends StatelessWidget { + final HelpField field; + final AppColors colors; + + const _FieldDescription({required this.field, required this.colors}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.hairline)), + ), + child: MergeSemantics( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field.name, + style: AppTextStyles.rowLabel.copyWith(color: colors.textPrimary), + ), + const SizedBox(height: 2), + Text( + field.description, + style: AppTextStyles.listDesc.copyWith( + color: colors.textSecondary, + ), + ), + ], + ), + ), + ); + } +} + +class _UnderstoodButton extends StatelessWidget { + const _UnderstoodButton(); + + @override + Widget build(BuildContext context) { + final palette = CalculatorScope.of(context); + + return SizedBox( + height: 56, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: FilledButton.styleFrom( + backgroundColor: palette.solid, + foregroundColor: palette.onSolid, + shape: const StadiumBorder(), + textStyle: AppTextStyles.button, + ), + child: const Text('Entendi'), + ), + ); + } +} diff --git a/lib/core/widgets/help_sheet/methodology.dart b/lib/core/widgets/help_sheet/methodology.dart new file mode 100644 index 0000000..ec90fd5 --- /dev/null +++ b/lib/core/widgets/help_sheet/methodology.dart @@ -0,0 +1,57 @@ +import 'package:real_calc/core/themes/calculator_palette.dart'; + +class MethodologyItem { + final String title; + final String text; + + const MethodologyItem(this.title, this.text); +} + +/// Notas de metodologia da folha "Como funciona". Fica separado do +/// [HelpContent] para outras explicações, como SAC e Price, usarem a mesma +/// estrutura. +class Methodology { + static const title = 'Metodologia'; + + final List items; + + const Methodology(this.items); + + static Methodology? of(CalculatorKind kind) => switch (kind) { + CalculatorKind.correction => correction, + _ => null, + }; + + static const correction = Methodology([ + MethodologyItem( + 'Fator acumulado', + 'O fator junta as variações do índice no período, uma multiplicando a ' + 'outra. Com 1% em um mês e 2% no seguinte, o fator é 1,0302, e ' + 'não 1,03.', + ), + MethodologyItem( + 'Mês inicial e mês final', + 'Nos índices mensais, os dois meses entram no cálculo. De 01/2024 até ' + '03/2024 usa as variações de janeiro, fevereiro e março. Nos ' + 'índices diários, como CDI e Selic, a conta começa no dia seguinte ' + 'à data inicial.', + ), + MethodologyItem( + 'Um mês só', + 'Para corrigir um único mês, use o mesmo mês em De e Até. De 05/2024 ' + 'até 05/2024 aplica só a variação de maio.', + ), + MethodologyItem( + 'Valores antes do Real', + 'Antes de 07/1994, informe o valor na moeda da época, como cruzeiro ou ' + 'cruzado. O app não converte moedas: o resultado sai na mesma moeda ' + 'do valor informado.', + ), + MethodologyItem( + 'IGP-M', + 'Usamos a série de alta precisão do IGP-M no Banco Central, com dados ' + 'desde 06/1989. Em períodos que começam antes de 03/2021, o ' + 'resultado pode diferir em centavos de outras calculadoras.', + ), + ]); +} diff --git a/lib/core/widgets/input_forms.dart b/lib/core/widgets/input_forms.dart deleted file mode 100644 index 5981f97..0000000 --- a/lib/core/widgets/input_forms.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/themes/spacing.dart'; - -class InputForms extends StatelessWidget { - final String label; - final String hint; - final TextEditingController controller; - final Widget prefixIcon; - final Widget? suffixIcon; - final TextInputType keyboardType; - final TextInputAction? textInputAction; - final GestureTapCallback? onTap; - final String? Function(String?)? validator; - final List? inputFormatters; - - const InputForms({ - super.key, - required this.label, - required this.hint, - required this.controller, - required this.prefixIcon, - this.suffixIcon, - this.onTap, - this.textInputAction, - this.keyboardType = TextInputType.number, - this.validator, - this.inputFormatters, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.sm, - children: [ - Text( - label, - style: theme.textTheme.bodyMedium?.copyWith( - color: ColorTokens.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - TextFormField( - validator: validator, - controller: controller, - inputFormatters: inputFormatters, - keyboardType: keyboardType, - textInputAction: textInputAction, - onTap: onTap, - style: theme.textTheme.bodyLarge?.copyWith( - color: ColorTokens.textPrimary, - ), - decoration: InputDecoration( - hintText: hint, - hintStyle: - theme.inputDecorationTheme.hintStyle ?? - TextStyle(color: ColorTokens.textHint), - prefixIcon: prefixIcon, - suffixIcon: suffixIcon, - ), - ), - ], - ); - } -} diff --git a/lib/core/widgets/input_forms_result_card.dart b/lib/core/widgets/input_forms_result_card.dart deleted file mode 100644 index b8792e6..0000000 --- a/lib/core/widgets/input_forms_result_card.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:real_calc/core/themes/extensions/input_forms_result_card_theme.dart'; - -import 'types/input_field_state.dart'; - -class InputFormsResultCard extends StatelessWidget { - final String label; - final String hint; - final IconData icon; - final TextEditingController controller; - final String? Function(String?)? validator; - final List? inputFormatters; - final InputFieldState state; - final VoidCallback? onTap; - final bool readOnly; - final String? trailingBadge; - - const InputFormsResultCard({ - super.key, - required this.controller, - required this.label, - required this.hint, - required this.icon, - this.validator, - this.inputFormatters, - this.state = InputFieldState.neutral, - this.onTap, - this.readOnly = false, - this.trailingBadge, - }); - bool get _isHighlightedAndEmptyText { - if (state == InputFieldState.highlighted && controller.text.isEmpty) { - return true; - } - return false; - } - - Color _accentColor(InputFormsResultCardTheme theme) { - return switch (state) { - InputFieldState.neutral => theme.neutralIconColor, - InputFieldState.highlighted => theme.highlightedColor, - InputFieldState.calculated => theme.calculatedColor, - InputFieldState.error => theme.errorColor, - }; - } - - String? get _badgeText => switch (state) { - InputFieldState.highlighted => 'vazio', - InputFieldState.calculated => 'calculado', - InputFieldState.neutral || InputFieldState.error => null, - }; - - String? get _resolvedBadgeText => trailingBadge ?? _badgeText; - - String get _effectiveHint { - if (_isHighlightedAndEmptyText) { - return 'toque para calcular'; - } - return hint; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - final bool isNeutral = state == InputFieldState.neutral; - final Color accent = _accentColor(theme); - final String? badgeText = _resolvedBadgeText; - final String? errorText = validator?.call(controller.text); - - final bool isHintMessage = _isHighlightedAndEmptyText == true; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: isNeutral ? null : accent.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all( - color: isNeutral ? theme.neutralBorderColor : accent, - width: isNeutral ? 0.5 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - color: isNeutral ? theme.neutralLabelColor : accent, - ), - ), - if (badgeText != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - badgeText, - style: theme.badgeStyle.copyWith(color: accent), - ), - ), - ], - ), - const SizedBox(height: 6), - Row( - children: [ - Icon( - icon, - size: 18, - color: isNeutral ? theme.neutralIconColor : accent, - ), - const SizedBox(width: 12), - Expanded( - child: TextFormField( - controller: controller, - onTap: onTap, - readOnly: readOnly, - validator: validator, - inputFormatters: inputFormatters, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - textInputAction: TextInputAction.done, - enableInteractiveSelection: !readOnly, - showCursor: !readOnly, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - hintText: _effectiveHint, - hintStyle: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: isHintMessage - ? accent // cor de destaque quando é mensagem - : theme.neutralLabelColor, // cor normal do hint - ), - errorText: errorText, - errorStyle: theme.badgeStyle.copyWith( - color: theme.errorColor, - ), - isDense: true, - contentPadding: EdgeInsets.zero, - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - ), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/core/widgets/options_bottom_forms.dart b/lib/core/widgets/options_bottom_forms.dart deleted file mode 100644 index 69e7ef4..0000000 --- a/lib/core/widgets/options_bottom_forms.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/options_bottom_forms_theme.dart'; -import 'package:real_calc/core/themes/spacing.dart'; - -class OptionsBottomForms extends StatelessWidget { - final VoidCallback? onCalculate; - final VoidCallback? onClear; - final String calculateLabel; - final String clearLabel; - - const OptionsBottomForms({ - super.key, - this.onCalculate, - this.onClear, - this.calculateLabel = 'Calcular', - this.clearLabel = 'Limpar', - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final optionsTheme = theme.extension()!; - - return Row( - mainAxisSize: MainAxisSize.min, - spacing: AppSpacing.sm + 4, - children: [ - Expanded( - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onCalculate, - style: ElevatedButton.styleFrom( - backgroundColor: optionsTheme.calculateButtonBackground, - foregroundColor: optionsTheme.calculateButtonForeground, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - calculateLabel, - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - ), - ), - ), - ), - Expanded( - child: ElevatedButton( - onPressed: onClear, - style: ElevatedButton.styleFrom( - backgroundColor: optionsTheme.actionButtonBackground, - foregroundColor: optionsTheme.actionButtonForeground, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), - textStyle: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: optionsTheme.borderButtonColor), - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(clearLabel), - ), - ), - ), - ], - ); - } -} diff --git a/lib/core/widgets/page_header.dart b/lib/core/widgets/page_header.dart deleted file mode 100644 index 62374d6..0000000 --- a/lib/core/widgets/page_header.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/page_header_theme.dart'; - -class PageHeader extends StatelessWidget implements PreferredSizeWidget { - final String title; - final VoidCallback? onBack; - - const PageHeader({super.key, required this.title, this.onBack}); - - @override - Size get preferredSize => const Size.fromHeight(56); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - return SafeArea( - bottom: false, - child: SizedBox( - height: preferredSize.height, - child: Container( - color: theme.backgroundColor, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: theme.iconColor, size: 20), - onPressed: onBack ?? () => Navigator.of(context).maybePop(), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - title, - style: theme.titleStyle, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/core/widgets/result_details/result_details.dart b/lib/core/widgets/result_details/result_details.dart new file mode 100644 index 0000000..7cac1a5 --- /dev/null +++ b/lib/core/widgets/result_details/result_details.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +import 'result_summary.dart'; + +/// Detalhes do resultado, abaixo da lista de campos: título, barra de +/// composição com legenda e linhas de detalhe. Sem cartão, sombra ou borda. +/// +/// A cor de juros, rendimento ou correção vem do [CalculatorScope]. +class ResultDetails extends StatelessWidget { + static const double barHeight = 6; + static const double lineHeight = 32; + + final ResultSummary summary; + + const ResultDetails({super.key, required this.summary}); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final otherColor = + CalculatorScope.maybeOf(context)?.solid ?? + Theme.of(context).colorScheme.primary; + final composition = summary.composition; + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sectionGap), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + summary.title, + style: AppTextStyles.sectionLabel.copyWith( + color: colors.textSecondary, + ), + ), + if (composition != null) ...[ + const SizedBox(height: 12), + _CompositionBar( + composition: composition, + ownColor: colors.textPrimary, + otherColor: otherColor, + trackColor: colors.hairline, + ), + const SizedBox(height: 10), + ExcludeSemantics( + child: Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + children: [ + _LegendItem( + segment: composition.own, + color: colors.textPrimary, + textColor: colors.textSecondary, + ), + _LegendItem( + segment: composition.other, + color: otherColor, + textColor: colors.textSecondary, + ), + ], + ), + ), + ], + const SizedBox(height: AppSpacing.sm), + for (final line in summary.lines) + _DetailRow(line: line, colors: colors), + ], + ), + ); + } +} + +class _CompositionBar extends StatelessWidget { + final ResultComposition composition; + final Color ownColor; + final Color otherColor; + final Color trackColor; + + const _CompositionBar({ + required this.composition, + required this.ownColor, + required this.otherColor, + required this.trackColor, + }); + + @override + Widget build(BuildContext context) { + final own = composition.own.percent; + final other = composition.other.percent; + + return Semantics( + container: true, + label: composition.semanticsLabel, + child: ClipRRect( + borderRadius: BorderRadius.circular(ResultDetails.barHeight / 2), + child: SizedBox( + height: ResultDetails.barHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (own > 0) + Expanded( + flex: own, + child: ColoredBox(color: ownColor), + ), + if (own > 0 && other > 0) const SizedBox(width: 2), + if (other > 0) + Expanded( + flex: other, + child: ColoredBox(color: otherColor), + ), + if (own == 0 && other == 0) + Expanded(child: ColoredBox(color: trackColor)), + ], + ), + ), + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final ResultSegment segment; + final Color color; + final Color textColor; + + const _LegendItem({ + required this.segment, + required this.color, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + '${segment.label} ${segment.percent}%', + style: AppTextStyles.caption.copyWith(color: textColor), + ), + ], + ); + } +} + +class _DetailRow extends StatelessWidget { + final ResultDetailLine line; + final AppColors colors; + + const _DetailRow({required this.line, required this.colors}); + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: ResultDetails.lineHeight), + child: Row( + children: [ + Expanded( + child: Text( + line.label, + style: AppTextStyles.detailLabel.copyWith( + color: colors.textSecondary, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Text( + line.value, + softWrap: false, + style: AppTextStyles.detailValue.copyWith( + color: colors.textPrimary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/result_details/result_summary.dart b/lib/core/widgets/result_details/result_summary.dart new file mode 100644 index 0000000..5e43e4f --- /dev/null +++ b/lib/core/widgets/result_details/result_summary.dart @@ -0,0 +1,81 @@ +/// Um lado da barra de composição: rótulo e percentual inteiro. +class ResultSegment { + final String label; + final int percent; + + const ResultSegment(this.label, this.percent); + + @override + bool operator ==(Object other) => + other is ResultSegment && + other.label == label && + other.percent == percent; + + @override + int get hashCode => Object.hash(label, percent); + + @override + String toString() => '$label $percent%'; +} + +/// Linha de detalhe: rótulo à esquerda, valor já formatado à direita. +class ResultDetailLine { + final String label; + final String value; + + const ResultDetailLine(this.label, this.value); + + @override + bool operator ==(Object other) => + other is ResultDetailLine && other.label == label && other.value == value; + + @override + int get hashCode => Object.hash(label, value); + + @override + String toString() => '$label: $value'; +} + +/// Composição do resultado: o que é da pessoa ([own]) e o que é juros, +/// rendimento ou correção ([other]). Os percentuais somam 100. +class ResultComposition { + final ResultSegment own; + final ResultSegment other; + + const ResultComposition._(this.own, this.other); + + /// Calcula os percentuais a partir dos valores em centavos. O lado [own] é + /// arredondado e o [other] fica com o restante, para a soma dar 100. + /// Devolve `null` se o total não for positivo ou se alguma parte for + /// negativa (ex.: deflação), casos em que a barra não faz sentido. + static ResultComposition? fromCents({ + required String ownLabel, + required int ownCents, + required String otherLabel, + required int otherCents, + }) { + final total = ownCents + otherCents; + if (total <= 0 || ownCents < 0 || otherCents < 0) return null; + final ownPercent = (ownCents * 100 / total).round(); + return ResultComposition._( + ResultSegment(ownLabel, ownPercent), + ResultSegment(otherLabel, 100 - ownPercent), + ); + } + + /// Texto lido pelo TalkBack, ex.: "Emprestado 88%, Juros 12%". + String get semanticsLabel => '$own, $other'; +} + +/// Tudo o que o [ResultDetails] mostra abaixo da lista de campos. +class ResultSummary { + final String title; + final ResultComposition? composition; + final List lines; + + const ResultSummary({ + required this.title, + required this.composition, + required this.lines, + }); +} diff --git a/lib/core/widgets/theme_toggle_button.dart b/lib/core/widgets/theme_toggle_button.dart new file mode 100644 index 0000000..dd3a131 --- /dev/null +++ b/lib/core/widgets/theme_toggle_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:real_calc/core/themes/theme_cubit.dart'; + +/// Botão que alterna entre o tema claro e o escuro. +/// +/// Mostra a lua no tema claro e o sol no escuro, sem fundo. A cor padrão é +/// branca, pensada para a faixa colorida da tela inicial (#119). +class ThemeToggleButton extends StatelessWidget { + static const double touchSize = 44; + + final Color color; + + const ThemeToggleButton({super.key, this.color = Colors.white}); + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + final isDark = brightness == Brightness.dark; + final label = isDark + ? 'Mudar para o modo claro' + : 'Mudar para o modo escuro'; + + return MergeSemantics( + child: Semantics( + label: label, + child: SizedBox.square( + dimension: touchSize, + child: IconButton( + onPressed: () => context.read().toggle(brightness), + padding: EdgeInsets.zero, + color: color, + icon: AppIcon(isDark ? AppIcons.sun : AppIcons.moon, size: 22), + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/title_widget.dart b/lib/core/widgets/title_widget.dart deleted file mode 100644 index b7df79e..0000000 --- a/lib/core/widgets/title_widget.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/title_widget_theme.dart'; -import 'package:real_calc/core/themes/spacing.dart'; - -class TitleWidget extends StatelessWidget { - final String title; - final String subtitle; - final bool? invert; - - const TitleWidget({ - super.key, - required this.title, - required this.subtitle, - this.invert = false, - }); - - @override - Widget build(BuildContext context) { - final styles = Theme.of(context).extension()!; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.sm, - children: [ - Text( - title, - style: invert == false ? styles.titleStyle : styles.subtitleStyle, - ), - Text( - subtitle, - style: invert == false ? styles.subtitleStyle : styles.titleStyle, - ), - ], - ); - } -} diff --git a/lib/core/widgets/types/input_field_state.dart b/lib/core/widgets/types/input_field_state.dart index aa2d812..5f46ffe 100644 --- a/lib/core/widgets/types/input_field_state.dart +++ b/lib/core/widgets/types/input_field_state.dart @@ -1 +1 @@ -enum InputFieldState { neutral, highlighted, calculated, error } \ No newline at end of file +enum InputFieldState { neutral, unknown, calculated, error } \ No newline at end of file diff --git a/lib/core/widgets/warning_banner.dart b/lib/core/widgets/warning_banner.dart deleted file mode 100644 index 9d331e3..0000000 --- a/lib/core/widgets/warning_banner.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../themes/extensions/warning_banner_theme.dart'; - -class WarningBanner extends StatelessWidget { - final String message; - - const WarningBanner({super.key, required this.message}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - return Container( - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all(color: theme.borderColor), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.warning_amber_rounded, - color: theme.iconColor, - size: theme.iconSize, - ), - SizedBox(width: theme.iconTextSpacing), - Expanded(child: Text(message, style: theme.textStyle)), - ], - ), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index acc26b9..5ed19b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/date_symbol_data_local.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'core/themes/theme_cubit.dart'; import 'modules/app_module.dart'; import 'modules/app_widget.dart'; -void main() { - initializeDateFormatting('pt_BR', null); +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { + yield LicenseEntryWithLineBreaks([ + 'Manrope', + ], await rootBundle.loadString('assets/fonts/Manrope-OFL.txt')); + yield LicenseEntryWithLineBreaks([ + 'Bricolage Grotesque', + ], await rootBundle.loadString('assets/fonts/Bricolage-Grotesque-OFL.txt')); + }); + await initializeDateFormatting('pt_BR', null); + // Lido antes do primeiro frame para o app já abrir no tema escolhido. + final preferences = await SharedPreferences.getInstance(); runApp( - ModularApp( - module: AppModule(), - child: const AppWidget(), + BlocProvider( + create: (_) => ThemeCubit(preferences), + child: ModularApp(module: AppModule(), child: const AppWidget()), ), ); } diff --git a/lib/modules/app_widget.dart b/lib/modules/app_widget.dart index b5b86c8..0b82b8a 100644 --- a/lib/modules/app_widget.dart +++ b/lib/modules/app_widget.dart @@ -1,29 +1,41 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import '../core/themes/app_colors.dart'; import '../core/themes/app_theme.dart'; +import '../core/themes/theme_cubit.dart'; class AppWidget extends StatelessWidget { const AppWidget({super.key}); @override Widget build(BuildContext context) { - return MaterialApp.router( - theme: AppTheme.darkTheme, - darkTheme: AppTheme.lightTheme, - themeMode: ThemeMode.dark, - routeInformationParser: Modular.routeInformationParser, - routerDelegate: Modular.routerDelegate, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: const [ - Locale('pt', 'BR'), - ], - locale: const Locale('pt', 'BR'), + return BlocBuilder( + builder: (context, themeMode) => MaterialApp.router( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + builder: (context, child) { + final colors = + Theme.of(context).extension() ?? AppColors.dark; + return AnnotatedRegion( + value: AppTheme.systemUiOverlayStyle(colors), + child: child ?? const SizedBox.shrink(), + ); + }, + routeInformationParser: Modular.routeInformationParser, + routerDelegate: Modular.routerDelegate, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('pt', 'BR')], + locale: const Locale('pt', 'BR'), + ), ); } } diff --git a/lib/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart b/lib/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart index ed1498f..1798533 100644 --- a/lib/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart +++ b/lib/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart @@ -86,7 +86,7 @@ class FinancingFormCubit extends Cubit { return InputFieldState.neutral; } - return InputFieldState.highlighted; + return InputFieldState.unknown; } void _setFormattedValue( @@ -118,7 +118,7 @@ class FinancingFormCubit extends Cubit { _suppressRecompute = true; _setFormattedValue(initialValue, result.initialValue); months.text = result.periods > 0 ? result.periods.toString() : ''; - _setFormattedValue(rate, result.rate, fractionDigits: 4); + _setFormattedValue(rate, result.rate); _setFormattedValue(finalValue, result.finalValue); _suppressRecompute = false; diff --git a/lib/modules/financial_calculators/presentation/models/field_spec.dart b/lib/modules/financial_calculators/presentation/models/field_spec.dart deleted file mode 100644 index b90a136..0000000 --- a/lib/modules/financial_calculators/presentation/models/field_spec.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/material.dart'; - -class FieldSpec { - final T field; - final String label; - final String hint; - final IconData icon; - final List formatters; - - const FieldSpec({ - required this.field, - required this.label, - required this.hint, - required this.icon, - required this.formatters, - }); -} diff --git a/lib/modules/financial_calculators/presentation/pages/financing_page.dart b/lib/modules/financial_calculators/presentation/pages/financing_page.dart index e4bd7d6..360b60a 100644 --- a/lib/modules/financial_calculators/presentation/pages/financing_page.dart +++ b/lib/modules/financial_calculators/presentation/pages/financing_page.dart @@ -1,122 +1,91 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter/services.dart'; -import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/models/field_spec.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/widgets/forms_view.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/services/share_plus_result_sharer.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/utils/decimal_input_formatter.dart'; -import '../../../../core/utils/decimal_input_formatter.dart'; import '../../domain/enum/financial_calculation_target.dart'; -import '../../../../core/widgets/types/input_field_state.dart'; import '../cubit/financing/financing_cubit.dart'; import '../cubit/forms/financing_form_cubit.dart'; +import '../utils/financing_statement.dart'; +import '../utils/result_summaries.dart'; +import '../utils/share_texts.dart'; +import '../widgets/calculator_form_view.dart'; class FinancingPage extends StatelessWidget { - static final List> _fieldSpecs = [ - FieldSpec( - field: FinancialCalculationTarget.initialValue, - label: 'Valor financiado (R\$)', + static const title = 'Financiamento'; + + static final fields = [ + CalculatorFieldConfig( + target: FinancialCalculationTarget.initialValue, + label: 'Valor financiado', + prefix: 'R\$', hint: '0,00', - icon: Icons.attach_money, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.periods, - label: 'Prazo (meses)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.periods, + label: 'Prazo', + unit: 'meses', hint: '0', - icon: Icons.calendar_today, + keyboardType: TextInputType.number, formatters: [FilteringTextInputFormatter.digitsOnly], ), - FieldSpec( - field: FinancialCalculationTarget.interestRate, - label: 'Taxa de juros (% ao mês)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.interestRate, + label: 'Taxa de juros', + unit: '% a.m.', hint: '0,00', - icon: Icons.percent, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.finalValue, - label: 'Valor da prestação', + CalculatorFieldConfig( + target: FinancialCalculationTarget.finalValue, + label: 'Prestação', + prefix: 'R\$', + secondLine: 'todo mês', hint: '0,00', - icon: Icons.payments, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), ]; - const FinancingPage({super.key}); - - BlocListener _buildForms() { - return BlocListener( - listener: (context, state) { - final formCubit = context.read(); - if (state is FinancingCalculated) { - formCubit.applyResult(state.value, state.calculatedField); - } else if (state is FinancingError) { - formCubit.invalidateCalculatedField(); - } - }, - child: BlocBuilder( - builder: (context, financingState) { - final formCubit = context.watch(); - final formState = formCubit.state; - - void calculate() { - if (formState.canCalculate) { - context.read().calculate(formCubit.financing); - } - } + final ResultSharer sharer; - return FormsView( - fieldSpecs: _fieldSpecs, - controllerFor: formCubit.controllerFor, - stateOf: formState.stateOf, - isLoading: financingState is FinancingLoading, - errorMessage: financingState is FinancingError - ? financingState.message ?? 'Erro ao calcular.' - : null, - onCalculate: calculate, - onClear: formCubit.clear, - onFieldTap: (field) { - if (formState.stateOf(field) == InputFieldState.highlighted) { - calculate(); - } - }, - ); - }, - ), - ); - } + const FinancingPage({super.key, this.sharer = const SharePlusResultSharer()}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: const PageHeader(title: 'Financiamento'), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md + 4, - vertical: AppSpacing.md + 4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.lg, - children: [ - const TitleWidget( - title: 'Financiamento', - subtitle: 'Calcule o valor da prestação do seu financiamento.', - ), - const HelpCard( - message: - 'Preencha 3 campos e toque no 4° para calcular automaticamente', - ), - _buildForms(), - ], - ), + return CalculatorScope( + kind: CalculatorKind.financing, + child: BlocConsumer( + listener: (context, state) { + final formCubit = context.read(); + if (state is FinancingCalculated) { + formCubit.applyResult(state.value, state.calculatedField); + } else if (state is FinancingError) { + formCubit.invalidateCalculatedField(); + } + }, + builder: (context, state) => CalculatorFormView( + kind: CalculatorKind.financing, + title: title, + fields: fields, + statement: financingStatement, + missingMessage: financingMissingFieldsMessage, + summary: financingResultSummary, + onCalculate: context.read().calculate, + shareText: financingShareText, + sharer: sharer, + errorMessage: state is FinancingError + ? state.message ?? 'Confira os valores.' + : null, ), ), ); } -} \ No newline at end of file +} diff --git a/lib/modules/financial_calculators/presentation/pages/future_value_page.dart b/lib/modules/financial_calculators/presentation/pages/future_value_page.dart index cae86aa..d1803e0 100644 --- a/lib/modules/financial_calculators/presentation/pages/future_value_page.dart +++ b/lib/modules/financial_calculators/presentation/pages/future_value_page.dart @@ -1,120 +1,91 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/models/field_spec.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/services/share_plus_result_sharer.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/utils/decimal_input_formatter.dart'; -import '../../../../core/utils/decimal_input_formatter.dart'; import '../../domain/enum/financial_calculation_target.dart'; -import '../../../../core/widgets/types/input_field_state.dart'; import '../cubit/forms/financing_form_cubit.dart'; import '../cubit/future_value/future_value_cubit.dart'; -import '../widgets/forms_view.dart'; +import '../utils/future_value_statement.dart'; +import '../utils/result_summaries.dart'; +import '../utils/share_texts.dart'; +import '../widgets/calculator_form_view.dart'; class FutureValuePage extends StatelessWidget { - static final List> _fieldSpecs = [ - FieldSpec( - field: FinancialCalculationTarget.initialValue, - label: 'Capital (R\$)', + static const title = 'Valor Futuro'; + + static final fields = [ + CalculatorFieldConfig( + target: FinancialCalculationTarget.initialValue, + label: 'Valor aplicado', + prefix: 'R\$', hint: '0,00', - icon: Icons.attach_money, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.periods, - label: 'Prazo (meses)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.periods, + label: 'Prazo', + unit: 'meses', hint: '0', - icon: Icons.calendar_today, + keyboardType: TextInputType.number, formatters: [FilteringTextInputFormatter.digitsOnly], ), - FieldSpec( - field: FinancialCalculationTarget.interestRate, - label: 'Taxa de juros (% ao mês)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.interestRate, + label: 'Taxa de juros', + unit: '% a.m.', hint: '0,00', - icon: Icons.percent, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.finalValue, - label: 'Valor final', + CalculatorFieldConfig( + target: FinancialCalculationTarget.finalValue, + label: 'Valor futuro', + prefix: 'R\$', hint: '0,00', - icon: Icons.payments, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), ]; - const FutureValuePage({super.key}); - - BlocListener _buildForms() { - return BlocListener( - listener: (context, state) { - final formCubit = context.read(); - if (state is FutureValueCalculated) { - formCubit.applyResult(state.value, state.calculatedField); - } else if (state is FutureValueError) { - formCubit.invalidateCalculatedField(); - } - }, - child: BlocBuilder( - builder: (context, financingState) { - final formCubit = context.watch(); - final formState = formCubit.state; + final ResultSharer sharer; - void calculate() { - if (formState.canCalculate) { - context.read().calculate(formCubit.financing); - } - } - - return FormsView( - fieldSpecs: _fieldSpecs, - controllerFor: formCubit.controllerFor, - stateOf: formState.stateOf, - isLoading: financingState is FutureValueLoading, - errorMessage: financingState is FutureValueError - ? financingState.message ?? 'Erro ao calcular.' - : null, - onCalculate: calculate, - onClear: formCubit.clear, - onFieldTap: (field) { - if (formState.stateOf(field) == InputFieldState.highlighted) { - calculate(); - } - }, - ); - }, - ), - ); - } + const FutureValuePage({ + super.key, + this.sharer = const SharePlusResultSharer(), + }); @override Widget build(BuildContext context) { - return Scaffold( - appBar: PageHeader(title: 'Valor Futuro'), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md + 4, - vertical: AppSpacing.md + 4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.lg, - children: [ - const TitleWidget( - title: 'Valor Futuro', - subtitle: 'Calcule o valor futuro do seu capital.', - ), - const HelpCard( - message: - 'Preencha 3 campos e toque no 4° para calcular automaticamente', - ), - _buildForms(), - ], - ), + return CalculatorScope( + kind: CalculatorKind.futureValue, + child: BlocConsumer( + listener: (context, state) { + final formCubit = context.read(); + if (state is FutureValueCalculated) { + formCubit.applyResult(state.value, state.calculatedField); + } else if (state is FutureValueError) { + formCubit.invalidateCalculatedField(); + } + }, + builder: (context, state) => CalculatorFormView( + kind: CalculatorKind.futureValue, + title: title, + fields: fields, + statement: futureValueStatement, + missingMessage: futureValueMissingFieldsMessage, + summary: futureValueResultSummary, + onCalculate: context.read().calculate, + shareText: futureValueShareText, + sharer: sharer, + errorMessage: state is FutureValueError + ? state.message ?? 'Confira os valores.' + : null, ), ), ); diff --git a/lib/modules/financial_calculators/presentation/pages/regular_deposits_page.dart b/lib/modules/financial_calculators/presentation/pages/regular_deposits_page.dart index 489d62a..b6c097a 100644 --- a/lib/modules/financial_calculators/presentation/pages/regular_deposits_page.dart +++ b/lib/modules/financial_calculators/presentation/pages/regular_deposits_page.dart @@ -1,122 +1,92 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; -import '../../../../core/utils/decimal_input_formatter.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/services/share_plus_result_sharer.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/utils/decimal_input_formatter.dart'; + import '../../domain/enum/financial_calculation_target.dart'; -import '../../../../core/widgets/types/input_field_state.dart'; import '../cubit/forms/financing_form_cubit.dart'; import '../cubit/regular_deposits/regular_deposits_cubit.dart'; -import '../models/field_spec.dart'; -import '../widgets/forms_view.dart'; +import '../utils/deposits_statement.dart'; +import '../utils/result_summaries.dart'; +import '../utils/share_texts.dart'; +import '../widgets/calculator_form_view.dart'; class RegularDepositsPage extends StatelessWidget { - static final List> _fieldSpecs = [ - FieldSpec( - field: FinancialCalculationTarget.initialValue, - label: 'Valor do depósito (R\$)', + static const title = 'Depósitos Regulares'; + + static final fields = [ + CalculatorFieldConfig( + target: FinancialCalculationTarget.initialValue, + label: 'Depósito', + prefix: 'R\$', + secondLine: 'todo mês', hint: '0,00', - icon: Icons.savings_outlined, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.periods, - label: 'Prazo (meses)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.periods, + label: 'Prazo', + unit: 'meses', hint: '0', - icon: Icons.calendar_today_outlined, + keyboardType: TextInputType.number, formatters: [FilteringTextInputFormatter.digitsOnly], ), - FieldSpec( - field: FinancialCalculationTarget.interestRate, - label: 'Taxa de juros (% ao mês)', + CalculatorFieldConfig( + target: FinancialCalculationTarget.interestRate, + label: 'Taxa de juros', + unit: '% a.m.', hint: '0,00', - icon: Icons.percent, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), - FieldSpec( - field: FinancialCalculationTarget.finalValue, + CalculatorFieldConfig( + target: FinancialCalculationTarget.finalValue, label: 'Valor final', + prefix: 'R\$', hint: '0,00', - icon: Icons.wallet_outlined, + keyboardType: const TextInputType.numberWithOptions(decimal: true), formatters: [DecimalInputFormatter()], ), ]; - const RegularDepositsPage({super.key}); - - BlocListener _buildForms() { - return BlocListener( - listener: (context, state) { - final formCubit = context.read(); - if (state is RegularDepositsCalculated) { - formCubit.applyResult(state.value, state.calculatedField); - } else if (state is RegularDepositsError) { - formCubit.invalidateCalculatedField(); - } - }, - child: BlocBuilder( - builder: (context, financingState) { - final formCubit = context.watch(); - final formState = formCubit.state; + final ResultSharer sharer; - void calculate() { - if (formState.canCalculate) { - context.read().calculate( - formCubit.financing, - ); - } - } - - return FormsView( - fieldSpecs: _fieldSpecs, - controllerFor: formCubit.controllerFor, - stateOf: formState.stateOf, - isLoading: financingState is RegularDepositsLoading, - errorMessage: financingState is RegularDepositsError - ? financingState.message ?? 'Erro ao calcular.' - : null, - onCalculate: calculate, - onClear: formCubit.clear, - onFieldTap: (field) { - if (formState.stateOf(field) == InputFieldState.highlighted) { - calculate(); - } - }, - ); - }, - ), - ); - } + const RegularDepositsPage({ + super.key, + this.sharer = const SharePlusResultSharer(), + }); @override Widget build(BuildContext context) { - return Scaffold( - appBar: PageHeader(title: 'Depósitos Regulares'), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md + 4, - vertical: AppSpacing.md + 4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.lg, - children: [ - const TitleWidget( - title: 'Depósitos Regulares', - subtitle: - 'Calcule o valor futuro, a taxa, o prazo ou o depósito mensal.', - ), - const HelpCard( - message: - 'Preencha 3 campos e toque no 4° para calcular automaticamente', - ), - _buildForms(), - ], - ), + return CalculatorScope( + kind: CalculatorKind.deposits, + child: BlocConsumer( + listener: (context, state) { + final formCubit = context.read(); + if (state is RegularDepositsCalculated) { + formCubit.applyResult(state.value, state.calculatedField); + } else if (state is RegularDepositsError) { + formCubit.invalidateCalculatedField(); + } + }, + builder: (context, state) => CalculatorFormView( + kind: CalculatorKind.deposits, + title: title, + fields: fields, + statement: depositsStatement, + missingMessage: depositsMissingFieldsMessage, + summary: regularDepositsResultSummary, + onCalculate: context.read().calculate, + shareText: depositsShareText, + sharer: sharer, + errorMessage: state is RegularDepositsError + ? state.message ?? 'Confira os valores.' + : null, ), ), ); diff --git a/lib/modules/financial_calculators/presentation/utils/calculator_statement.dart b/lib/modules/financial_calculators/presentation/utils/calculator_statement.dart new file mode 100644 index 0000000..e5ac642 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/calculator_statement.dart @@ -0,0 +1,80 @@ +import 'package:intl/intl.dart'; +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +import '../../domain/enum/financial_calculation_target.dart'; + +const calculatorFieldOrder = [ + FinancialCalculationTarget.initialValue, + FinancialCalculationTarget.periods, + FinancialCalculationTarget.interestRate, + FinancialCalculationTarget.finalValue, +]; + +typedef FieldName = String Function(FinancialCalculationTarget field); + +final _money = NumberFormat('#,##0.00', 'pt_BR'); + +String moneyValue(double value) => _money.format(toCents(value) / 100); + +String money(double value) => formatCents(toCents(value)); + +String months(int count) => count == 1 ? '1 mês' : '$count meses'; + +BandStatement pendingStatement( + Set emptyFields, + FieldName fieldName, +) { + if (emptyFields.length == calculatorFieldOrder.length) { + return const BandStatement( + kicker: 'Informe três valores', + value: 'e descubra o quarto', + sub: 'Qualquer campo pode ser o resultado.', + ); + } + + if (emptyFields.length == 1) { + return BandStatement( + kicker: 'Você quer descobrir', + value: fieldName(emptyFields.single), + sub: 'Os outros três campos já estão preenchidos.', + ); + } + + if (emptyFields.isEmpty) { + return const BandStatement( + kicker: 'Para calcular, deixe', + value: '1 campo em branco', + sub: 'Apague o valor que você quer descobrir.', + ); + } + + final missing = emptyFields.length - 1; + return BandStatement( + kicker: 'Para calcular, ainda', + value: missing == 1 ? 'falta 1 campo' : 'faltam $missing campos', + sub: 'Deixe em branco só o que você quer descobrir.', + ); +} + +String missingFieldsMessage( + Set emptyFields, + FieldName fieldName, +) { + final names = [ + for (final field in calculatorFieldOrder) + if (emptyFields.contains(field)) fieldName(field), + ]; + final list = names.length == 1 + ? names.single + : '${names.sublist(0, names.length - 1).join(', ')} ou ${names.last}'; + return 'Preencha $list. O campo que ficar em branco é calculado.'; +} + +BandStatement rateStatement(double monthlyRate, {required String kicker}) { + return BandStatement( + kicker: kicker, + value: '${formatPercent(monthlyRate)} ao mês', + sub: '${formatPercent(annualEquivalentRate(monthlyRate))} ao ano', + ); +} diff --git a/lib/modules/financial_calculators/presentation/utils/deposits_statement.dart b/lib/modules/financial_calculators/presentation/utils/deposits_statement.dart new file mode 100644 index 0000000..2c95ea8 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/deposits_statement.dart @@ -0,0 +1,57 @@ +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +import '../../domain/entities/financial_calculation.dart'; +import '../../domain/enum/financial_calculation_target.dart'; +import 'calculator_statement.dart'; + +String depositsFieldName(FinancialCalculationTarget field) => switch (field) { + FinancialCalculationTarget.initialValue => 'o depósito', + FinancialCalculationTarget.periods => 'o prazo', + FinancialCalculationTarget.interestRate => 'a taxa de juros', + FinancialCalculationTarget.finalValue => 'o valor final', +}; + +BandStatement depositsStatement({ + required Set emptyFields, + required FinancialCalculation values, + FinancialCalculationTarget? calculatedField, +}) { + if (calculatedField == null) { + return pendingStatement(emptyFields, depositsFieldName); + } + + final deposit = money(values.initialValue); + final total = money(values.finalValue); + final yieldCents = + toCents(values.finalValue) - + toCents(values.initialValue) * values.periods; + final yieldText = '${formatCents(yieldCents)} de rendimento'; + + return switch (calculatedField) { + FinancialCalculationTarget.finalValue => BandStatement( + kicker: 'Guardando $deposit por mês, você terá', + value: moneyValue(values.finalValue), + isMoney: true, + sub: 'em ${months(values.periods)}, com $yieldText', + ), + FinancialCalculationTarget.initialValue => BandStatement( + kicker: 'Para juntar $total, guarde por mês', + value: moneyValue(values.initialValue), + isMoney: true, + sub: 'durante ${months(values.periods)}', + ), + FinancialCalculationTarget.periods => BandStatement( + kicker: 'Guardando $deposit por mês, você junta $total em', + value: months(values.periods), + sub: 'com $yieldText', + ), + FinancialCalculationTarget.interestRate => rateStatement( + values.rate, + kicker: 'Para isso, o dinheiro precisa render', + ), + }; +} + +String depositsMissingFieldsMessage(Set empty) => + missingFieldsMessage(empty, depositsFieldName); diff --git a/lib/modules/financial_calculators/presentation/utils/financing_statement.dart b/lib/modules/financial_calculators/presentation/utils/financing_statement.dart new file mode 100644 index 0000000..7871fe7 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/financing_statement.dart @@ -0,0 +1,56 @@ +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +import '../../domain/entities/financial_calculation.dart'; +import '../../domain/enum/financial_calculation_target.dart'; +import 'calculator_statement.dart'; + +String financingFieldName(FinancialCalculationTarget field) => switch (field) { + FinancialCalculationTarget.initialValue => 'o valor financiado', + FinancialCalculationTarget.periods => 'o prazo', + FinancialCalculationTarget.interestRate => 'a taxa de juros', + FinancialCalculationTarget.finalValue => 'a prestação', +}; + +BandStatement financingStatement({ + required Set emptyFields, + required FinancialCalculation values, + FinancialCalculationTarget? calculatedField, +}) { + if (calculatedField == null) { + return pendingStatement(emptyFields, financingFieldName); + } + + final installment = money(values.finalValue); + final periods = values.periods; + + return switch (calculatedField) { + FinancialCalculationTarget.finalValue => BandStatement( + kicker: 'Sua prestação vai ser', + value: moneyValue(values.finalValue), + isMoney: true, + sub: periods == 1 + ? '1 parcela, pela Tabela Price' + : '$periods parcelas iguais, pela Tabela Price', + ), + FinancialCalculationTarget.initialValue => BandStatement( + kicker: 'Você pode financiar', + value: moneyValue(values.initialValue), + isMoney: true, + sub: periods == 1 + ? 'em 1 parcela de $installment' + : 'em $periods parcelas de $installment', + ), + FinancialCalculationTarget.periods => BandStatement( + kicker: 'Você quita em', + value: months(periods), + sub: 'com parcelas de $installment', + ), + FinancialCalculationTarget.interestRate => rateStatement( + values.rate, + kicker: 'A taxa de juros é de', + ), + }; +} + +String financingMissingFieldsMessage(Set empty) => + missingFieldsMessage(empty, financingFieldName); diff --git a/lib/modules/financial_calculators/presentation/utils/future_value_statement.dart b/lib/modules/financial_calculators/presentation/utils/future_value_statement.dart new file mode 100644 index 0000000..3843190 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/future_value_statement.dart @@ -0,0 +1,56 @@ +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +import '../../domain/entities/financial_calculation.dart'; +import '../../domain/enum/financial_calculation_target.dart'; +import 'calculator_statement.dart'; + +String futureValueFieldName(FinancialCalculationTarget field) => + switch (field) { + FinancialCalculationTarget.initialValue => 'o valor aplicado', + FinancialCalculationTarget.periods => 'o prazo', + FinancialCalculationTarget.interestRate => 'a taxa de juros', + FinancialCalculationTarget.finalValue => 'o valor futuro', + }; + +BandStatement futureValueStatement({ + required Set emptyFields, + required FinancialCalculation values, + FinancialCalculationTarget? calculatedField, +}) { + if (calculatedField == null) { + return pendingStatement(emptyFields, futureValueFieldName); + } + + final applied = money(values.initialValue); + final future = money(values.finalValue); + final yieldCents = toCents(values.finalValue) - toCents(values.initialValue); + final yieldText = '${formatCents(yieldCents)} de rendimento'; + + return switch (calculatedField) { + FinancialCalculationTarget.finalValue => BandStatement( + kicker: 'Aplicando $applied hoje, você terá', + value: moneyValue(values.finalValue), + isMoney: true, + sub: 'em ${months(values.periods)}, com $yieldText', + ), + FinancialCalculationTarget.initialValue => BandStatement( + kicker: 'Para ter $future, aplique hoje', + value: moneyValue(values.initialValue), + isMoney: true, + sub: 'por ${months(values.periods)}', + ), + FinancialCalculationTarget.periods => BandStatement( + kicker: '$applied viram $future em', + value: months(values.periods), + sub: 'com $yieldText', + ), + FinancialCalculationTarget.interestRate => rateStatement( + values.rate, + kicker: 'Para isso, o dinheiro precisa render', + ), + }; +} + +String futureValueMissingFieldsMessage(Set empty) => + missingFieldsMessage(empty, futureValueFieldName); diff --git a/lib/modules/financial_calculators/presentation/utils/result_summaries.dart b/lib/modules/financial_calculators/presentation/utils/result_summaries.dart new file mode 100644 index 0000000..a1a52d1 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/result_summaries.dart @@ -0,0 +1,79 @@ +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; + +import '../../domain/entities/financial_calculation.dart'; + +// Os valores derivados saem dos valores arredondados que a pessoa vê na tela +// (em centavos), para a conta fechar: 236,40 × 12 = 2.836,80. + +/// Financiamento: [FinancialCalculation.initialValue] é o valor emprestado e +/// [FinancialCalculation.finalValue], a prestação. +ResultSummary financingResultSummary(FinancialCalculation calculation) { + final borrowed = toCents(calculation.initialValue); + final total = toCents(calculation.finalValue) * calculation.periods; + final interest = total - borrowed; + + return ResultSummary( + title: 'Do total que você vai pagar', + composition: ResultComposition.fromCents( + ownLabel: 'Emprestado', + ownCents: borrowed, + otherLabel: 'Juros', + otherCents: interest, + ), + lines: [ + ResultDetailLine('Total pago', formatCents(total)), + ResultDetailLine('Total de juros', formatCents(interest)), + _annualRateLine(calculation.rate), + ], + ); +} + +/// Depósitos Regulares: [FinancialCalculation.initialValue] é o depósito +/// mensal e [FinancialCalculation.finalValue], o valor final. +ResultSummary regularDepositsResultSummary(FinancialCalculation calculation) { + final deposited = toCents(calculation.initialValue) * calculation.periods; + final yield_ = toCents(calculation.finalValue) - deposited; + + return ResultSummary( + title: 'Do valor final', + composition: ResultComposition.fromCents( + ownLabel: 'Depositado', + ownCents: deposited, + otherLabel: 'Rendimento', + otherCents: yield_, + ), + lines: [ + ResultDetailLine('Total depositado', formatCents(deposited)), + ResultDetailLine('Rendimento', formatCents(yield_)), + _annualRateLine(calculation.rate), + ], + ); +} + +/// Valor Futuro: [FinancialCalculation.initialValue] é o valor aplicado e +/// [FinancialCalculation.finalValue], o valor final. +ResultSummary futureValueResultSummary(FinancialCalculation calculation) { + final applied = toCents(calculation.initialValue); + final yield_ = toCents(calculation.finalValue) - applied; + + return ResultSummary( + title: 'Do valor futuro', + composition: ResultComposition.fromCents( + ownLabel: 'Valor aplicado', + ownCents: applied, + otherLabel: 'Rendimento', + otherCents: yield_, + ), + lines: [ + ResultDetailLine('Valor aplicado', formatCents(applied)), + ResultDetailLine('Rendimento', formatCents(yield_)), + _annualRateLine(calculation.rate), + ], + ); +} + +ResultDetailLine _annualRateLine(double monthlyPercent) => ResultDetailLine( + 'Taxa anual equivalente', + '${formatPercent(annualEquivalentRate(monthlyPercent))} a.a.', +); diff --git a/lib/modules/financial_calculators/presentation/utils/share_texts.dart b/lib/modules/financial_calculators/presentation/utils/share_texts.dart new file mode 100644 index 0000000..e2d25a2 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/utils/share_texts.dart @@ -0,0 +1,113 @@ +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/utils/share_text.dart'; + +import '../../domain/entities/financial_calculation.dart'; +import '../../domain/enum/financial_calculation_target.dart'; +import 'calculator_statement.dart'; +import 'deposits_statement.dart'; +import 'financing_statement.dart'; +import 'future_value_statement.dart'; + +typedef ShareTextBuilder = + String Function( + FinancialCalculation values, + FinancialCalculationTarget calculatedField, + ); + +List<(String, String)> _fieldLines( + FinancialCalculation values, + FinancialCalculationTarget calculatedField, + Map labels, +) => [ + for (final field in calculatorFieldOrder) + if (field != calculatedField) + ( + labels[field]!, + switch (field) { + FinancialCalculationTarget.initialValue => money(values.initialValue), + FinancialCalculationTarget.periods => months(values.periods), + FinancialCalculationTarget.interestRate => + '${formatPercent(values.rate)} ao mês', + FinancialCalculationTarget.finalValue => money(values.finalValue), + }, + ), +]; + +String financingShareText( + FinancialCalculation values, + FinancialCalculationTarget calculatedField, +) { + final total = toCents(values.finalValue) * values.periods; + final interest = total - toCents(values.initialValue); + + return shareText( + calculator: 'Financiamento', + statement: financingStatement( + emptyFields: const {}, + values: values, + calculatedField: calculatedField, + ), + lines: [ + ..._fieldLines(values, calculatedField, const { + FinancialCalculationTarget.initialValue: 'Valor financiado', + FinancialCalculationTarget.periods: 'Prazo', + FinancialCalculationTarget.interestRate: 'Taxa de juros', + FinancialCalculationTarget.finalValue: 'Prestação', + }), + ('Total pago', formatCents(total)), + ('Juros', formatCents(interest)), + ], + ); +} + +String depositsShareText( + FinancialCalculation values, + FinancialCalculationTarget calculatedField, +) { + final deposited = toCents(values.initialValue) * values.periods; + final yield_ = toCents(values.finalValue) - deposited; + + return shareText( + calculator: 'Depósitos Regulares', + statement: depositsStatement( + emptyFields: const {}, + values: values, + calculatedField: calculatedField, + ), + lines: [ + ..._fieldLines(values, calculatedField, const { + FinancialCalculationTarget.initialValue: 'Depósito mensal', + FinancialCalculationTarget.periods: 'Prazo', + FinancialCalculationTarget.interestRate: 'Taxa de juros', + FinancialCalculationTarget.finalValue: 'Valor final', + }), + ('Total depositado', formatCents(deposited)), + ('Rendimento', formatCents(yield_)), + ], + ); +} + +String futureValueShareText( + FinancialCalculation values, + FinancialCalculationTarget calculatedField, +) { + final yield_ = toCents(values.finalValue) - toCents(values.initialValue); + + return shareText( + calculator: 'Valor Futuro', + statement: futureValueStatement( + emptyFields: const {}, + values: values, + calculatedField: calculatedField, + ), + lines: [ + ..._fieldLines(values, calculatedField, const { + FinancialCalculationTarget.initialValue: 'Valor aplicado', + FinancialCalculationTarget.periods: 'Prazo', + FinancialCalculationTarget.interestRate: 'Taxa de juros', + FinancialCalculationTarget.finalValue: 'Valor futuro', + }), + ('Rendimento', formatCents(yield_)), + ], + ); +} diff --git a/lib/modules/financial_calculators/presentation/widgets/calculator_form_view.dart b/lib/modules/financial_calculators/presentation/widgets/calculator_form_view.dart new file mode 100644 index 0000000..7fd0c57 --- /dev/null +++ b/lib/modules/financial_calculators/presentation/widgets/calculator_form_view.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/field_list/field_list.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/form_notice.dart'; +import 'package:real_calc/core/widgets/help_sheet/help_sheet.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; + +import '../../domain/entities/financial_calculation.dart'; +import '../../domain/enum/financial_calculation_target.dart'; +import '../cubit/forms/financing_form_cubit.dart'; +import '../utils/share_texts.dart'; + +class CalculatorFieldConfig { + final FinancialCalculationTarget target; + final String label; + final String hint; + final TextInputType keyboardType; + final List formatters; + final String? prefix; + final String? unit; + final String? secondLine; + + const CalculatorFieldConfig({ + required this.target, + required this.label, + required this.hint, + required this.keyboardType, + required this.formatters, + this.prefix, + this.unit, + this.secondLine, + }); +} + +typedef StatementBuilder = + BandStatement Function({ + required Set emptyFields, + required FinancialCalculation values, + FinancialCalculationTarget? calculatedField, + }); + +class CalculatorFormView extends StatelessWidget { + final CalculatorKind kind; + final String title; + final List fields; + final StatementBuilder statement; + final String Function(Set) missingMessage; + final ResultSummary Function(FinancialCalculation) summary; + final ValueChanged onCalculate; + final ShareTextBuilder shareText; + final ResultSharer sharer; + final String? errorMessage; + + const CalculatorFormView({ + super.key, + required this.kind, + required this.title, + required this.fields, + required this.statement, + required this.missingMessage, + required this.summary, + required this.onCalculate, + required this.shareText, + required this.sharer, + this.errorMessage, + }); + + @override + Widget build(BuildContext context) { + final formCubit = context.watch(); + final formState = formCubit.state; + + final emptyFields = { + for (final field in FinancialCalculationTarget.values) + if (formCubit.controllerFor(field).text.trim().isEmpty) field, + }; + final calculatedField = formState.calculatedField; + final values = formCubit.financing; + final filled = + FinancialCalculationTarget.values.length - emptyFields.length; + + void calculate() { + if (formCubit.state.canCalculate) onCalculate(formCubit.financing); + } + + void clear() { + final snapshot = { + for (final field in FinancialCalculationTarget.values) + field: formCubit.controllerFor(field).text, + }; + final previouslyCalculated = formCubit.state.calculatedField; + FocusScope.of(context).unfocus(); + formCubit.clear(); + showFieldsClearedSnackBar( + context, + onUndo: () { + for (final MapEntry(key: field, value: text) in snapshot.entries) { + if (field != previouslyCalculated) { + formCubit.controllerFor(field).text = text; + } + } + if (previouslyCalculated != null) calculate(); + }, + ); + } + + return Scaffold( + body: Column( + children: [ + CalculatorBand( + title: title, + statement: statement( + emptyFields: emptyFields, + values: values, + calculatedField: calculatedField, + ), + onHelp: () => showHelpSheet(context, kind), + onClear: clear, + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FieldList( + children: [ + for (final field in fields) + FieldRow( + key: ValueKey(field.target), + label: field.label, + controller: formCubit.controllerFor(field.target), + state: formState.stateOf(field.target), + prefix: field.prefix, + unit: field.unit, + secondLine: field.secondLine, + hint: field.hint, + keyboardType: field.keyboardType, + inputFormatters: field.formatters, + onCalculate: calculate, + ), + ], + ), + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: FormNotice( + title: 'Não foi possível calcular', + message: errorMessage!, + isError: true, + ), + ) + else if (filled > 0 && filled < 3) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: FormNotice( + title: 'Faltam dados para calcular', + message: missingMessage(emptyFields), + ), + ), + if (calculatedField != null) + ResultDetails(summary: summary(values)), + ], + ), + ), + ), + BottomActionBar( + primaryLabel: 'Calcular', + onPrimary: formState.canCalculate ? calculate : null, + onShare: calculatedField == null + ? null + : (origin) => sharer.share( + shareText(values, calculatedField), + origin: origin, + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/financial_calculators/presentation/widgets/forms_view.dart b/lib/modules/financial_calculators/presentation/widgets/forms_view.dart deleted file mode 100644 index 82ac9af..0000000 --- a/lib/modules/financial_calculators/presentation/widgets/forms_view.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/financing_forms_theme.dart'; -import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/types/input_field_state.dart'; -import 'package:real_calc/core/widgets/options_bottom_forms.dart'; -import '../models/field_spec.dart'; -import '../../../../core/widgets/input_forms_result_card.dart'; - -class FormsView extends StatelessWidget { - final List> fieldSpecs; - final TextEditingController Function(T field) controllerFor; - final InputFieldState Function(T field) stateOf; - final bool isLoading; - final String? errorMessage; - final VoidCallback? onCalculate; - final VoidCallback? onClear; - final ValueChanged? onFieldTap; - - const FormsView({ - required this.fieldSpecs, - required this.controllerFor, - required this.stateOf, - this.isLoading = false, - this.errorMessage, - this.onCalculate, - this.onClear, - this.onFieldTap, - super.key, - }); - - @override - Widget build(BuildContext context) { - final styles = Theme.of(context).extension()!; - - return Form( - child: Column( - children: [ - if (errorMessage != null) ...[ - _ErrorBanner(message: errorMessage!, styles: styles), - SizedBox(height: AppSpacing.md), - ], - if (isLoading) - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: LinearProgressIndicator( - color: styles.progressIndicatorColor, - ), - ), - for (final spec in fieldSpecs) ...[ - InputFormsResultCard( - label: spec.label, - hint: spec.hint, - icon: spec.icon, - controller: controllerFor(spec.field), - inputFormatters: spec.formatters, - state: stateOf(spec.field), - onTap: () => onFieldTap?.call(spec.field), - ), - SizedBox(height: AppSpacing.md), - ], - SizedBox(height: AppSpacing.sm), - OptionsBottomForms(onCalculate: onCalculate, onClear: onClear), - ], - ), - ); - } -} - -class _ErrorBanner extends StatelessWidget { - final String message; - final FinancingFormsTheme styles; - - const _ErrorBanner({required this.message, required this.styles}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: styles.errorContainerPadding, - decoration: BoxDecoration( - color: styles.errorBackgroundColor, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: styles.errorBorderColor), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.warning_amber_rounded, - size: 18, - color: styles.errorBorderColor, - ), - const SizedBox(width: 10), - Expanded(child: Text(message, style: styles.errorTextStyle)), - ], - ), - ); - } -} diff --git a/lib/modules/home/domain/enum/menu_card_variant.dart b/lib/modules/home/domain/enum/menu_card_variant.dart deleted file mode 100644 index 591ec35..0000000 --- a/lib/modules/home/domain/enum/menu_card_variant.dart +++ /dev/null @@ -1,12 +0,0 @@ -enum MenuCardVariant { - /// Card quadrado usado no grid 2 colunas — mesma lógica de antes, - /// só sem a caixinha colorida atrás do ícone. - standard, - - /// Card quadrado usado no grid 2 colunas, mas com a caixinha colorida - /// atrás do ícone. - featured, - - /// Card quadrado usado no grid 2 colunas, mas com o card desabilitado. - disabled, -} \ No newline at end of file diff --git a/lib/modules/home/domain/enum/metric_status.dart b/lib/modules/home/domain/enum/metric_status.dart deleted file mode 100644 index 3768a1d..0000000 --- a/lib/modules/home/domain/enum/metric_status.dart +++ /dev/null @@ -1,5 +0,0 @@ -enum MetricStatus { - loading, - data, - offline -} \ No newline at end of file diff --git a/lib/modules/home/domain/enum/metric_trend.dart b/lib/modules/home/domain/enum/metric_trend.dart deleted file mode 100644 index f7daffb..0000000 --- a/lib/modules/home/domain/enum/metric_trend.dart +++ /dev/null @@ -1,5 +0,0 @@ -enum MetricTrend { - up, - down, - flat -} \ No newline at end of file diff --git a/lib/modules/home/presentation/components/calculator_list_item.dart b/lib/modules/home/presentation/components/calculator_list_item.dart new file mode 100644 index 0000000..7458bc0 --- /dev/null +++ b/lib/modules/home/presentation/components/calculator_list_item.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/radius.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; + +class CalculatorListItem extends StatelessWidget { + static const double iconTileSize = 40; + + final CalculatorKind kind; + final String iconAsset; + final String title; + final String description; + final VoidCallback onTap; + + const CalculatorListItem({ + super.key, + required this.kind, + required this.iconAsset, + required this.title, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); + final palette = CalculatorPalette.of( + kind, + brightness: Theme.of(context).brightness, + ); + + return MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints( + minHeight: AppSpacing.homeRowHeight, + ), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.hairline)), + ), + child: Row( + children: [ + Container( + width: iconTileSize, + height: iconTileSize, + decoration: BoxDecoration( + color: palette.solid, + borderRadius: BorderRadius.circular(AppRadius.iconTile), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconAsset, + width: 22, + height: 22, + colorFilter: ColorFilter.mode( + palette.onSolid, + BlendMode.srcIn, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: AppTextStyles.listTitle.copyWith( + color: colors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: AppTextStyles.listDesc.copyWith( + color: colors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + ExcludeSemantics( + child: AppIcon( + AppIcons.chevronRight, + size: 20, + color: colors.textHint, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/home/presentation/components/highlight_card.dart b/lib/modules/home/presentation/components/highlight_card.dart deleted file mode 100644 index 9dd27c0..0000000 --- a/lib/modules/home/presentation/components/highlight_card.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/highlight_card_theme.dart'; - -class HighlightCard extends StatelessWidget { - final VoidCallback? onTap; - - const HighlightCard({super.key, this.onTap}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - return Container( - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(theme.borderRadius), - gradient: LinearGradient( - colors: theme.gradientColors, - begin: theme.gradientBegin, - end: theme.gradientEnd, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Selic e Índices', style: theme.titleStyle), - const SizedBox(height: 8), - Text( - 'Acompanhe as taxas oficiais atualizadas diariamente pelo Banco Central.', - style: theme.subtitleStyle.copyWith( - color: theme.subtitleStyle.color?.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: onTap, - style: ElevatedButton.styleFrom( - backgroundColor: theme.buttonBackgroundColor.withValues(alpha: 0.2), - foregroundColor: theme.buttonForegroundColor, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: Text('CONSULTAR TAXAS', style: theme.buttonTextStyle), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/modules/home/presentation/components/menu_card.dart b/lib/modules/home/presentation/components/menu_card.dart deleted file mode 100644 index 578a1ac..0000000 --- a/lib/modules/home/presentation/components/menu_card.dart +++ /dev/null @@ -1,246 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/menu_card_theme.dart'; -import 'package:real_calc/modules/home/domain/enum/menu_card_variant.dart'; - -class MenuCard extends StatelessWidget { - final IconData icon; - final Color cardColor; - final Color iconColor; - final String title; - final String description; - final VoidCallback? onTap; - final MenuCardVariant variant; - - const MenuCard({ - super.key, - required this.icon, - required this.cardColor, - required this.iconColor, - required this.title, - required this.description, - this.onTap, - this.variant = MenuCardVariant.standard, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - switch (variant) { - case MenuCardVariant.featured: - return _FeaturedMenuCard( - icon: icon, - cardColor: cardColor, - iconColor: iconColor, - title: title, - description: description, - onTap: onTap, - theme: theme, - ); - case MenuCardVariant.disabled: - return _DisabledMenuCard(icon: icon, title: title, theme: theme); - case MenuCardVariant.standard: - return _StandardMenuCard( - icon: icon, - cardColor: cardColor, - iconColor: iconColor, - title: title, - description: description, - onTap: onTap, - theme: theme, - ); - } - } -} - -/// Card quadrado usado no grid 2 colunas. -class _StandardMenuCard extends StatelessWidget { - final IconData icon; - final Color cardColor; - final Color iconColor; - final String title; - final String description; - final VoidCallback? onTap; - final MenuCardTheme theme; - - const _StandardMenuCard({ - required this.icon, - required this.cardColor, - required this.iconColor, - required this.title, - required this.description, - required this.theme, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(theme.borderRadius), - child: LayoutBuilder( - builder: (context, constraints) { - final bool telaMuitoPequena = - constraints.maxHeight < theme.heightThreshold; - - return Container( - key: const Key('menu_card_container'), - padding: telaMuitoPequena ? theme.compactPadding : theme.padding, - width: double.infinity, - decoration: BoxDecoration( - color: cardColor, - borderRadius: BorderRadius.circular(0), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: iconColor, - size: telaMuitoPequena - ? theme.iconSizeCompact - : theme.iconSize, - ), - SizedBox(height: telaMuitoPequena ? 4 : 12), - Text( - title, - style: telaMuitoPequena - ? theme.titleStyleCompact - : theme.titleStyle, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - ), - Text( - description, - style: telaMuitoPequena - ? theme.descriptionStyleCompact - : theme.descriptionStyle, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - }, - ), - ); - } -} - -/// Card full-width horizontal com borda de destaque, pra UMA ação principal. -class _FeaturedMenuCard extends StatelessWidget { - final IconData icon; - final Color cardColor; - final Color iconColor; - final String title; - final String description; - final VoidCallback? onTap; - final MenuCardTheme theme; - - const _FeaturedMenuCard({ - required this.icon, - required this.cardColor, - required this.iconColor, - required this.title, - required this.description, - required this.theme, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(theme.borderRadius), - child: Container( - key: const Key('menu_card_container'), - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: cardColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border( - left: BorderSide(color: theme.featuredBorderColor, width: 2), - ), - ), - child: Row( - children: [ - Icon(icon, color: iconColor, size: theme.iconSize), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: theme.titleStyleFeatured, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - description, - style: theme.descriptionStyleFeatured, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Icon( - Icons.chevron_right, - color: theme.descriptionStyleFeatured.color?.withValues(alpha: 0.6), - size: 18, - ), - ], - ), - ), - ); - } -} - -/// Card full-width horizontal, sem interação, indicando indisponibilidade. -class _DisabledMenuCard extends StatelessWidget { - final IconData icon; - final String title; - final MenuCardTheme theme; - - const _DisabledMenuCard({ - required this.icon, - required this.title, - required this.theme, - }); - - @override - Widget build(BuildContext context) { - return Container( - key: const Key('menu_card_container'), - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - border: Border.all( - color: theme.disabledLabelColor.withValues(alpha: 0.15), - ), - borderRadius: BorderRadius.circular(theme.borderRadius), - ), - child: Row( - children: [ - Icon(icon, color: theme.disabledLabelColor, size: theme.iconSizeCompact), - const SizedBox(width: 12), - Expanded( - child: Text( - '$title · em breve', - style: theme.disabledTextStyle.copyWith( - color: theme.disabledLabelColor, - ), - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/modules/home/presentation/components/metric_card.dart b/lib/modules/home/presentation/components/metric_card.dart deleted file mode 100644 index ecbfd0a..0000000 --- a/lib/modules/home/presentation/components/metric_card.dart +++ /dev/null @@ -1,434 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:real_calc/core/themes/extensions/metric_card_theme.dart'; -import 'package:real_calc/modules/home/domain/enum/metric_status.dart'; - -import '../../domain/enum/metric_trend.dart'; -import 'sparkline_painter.dart'; - - -/// Card de métrica que exibe a taxa Selic atual, sua variação recente -/// e uma mini sparkline de tendência. Componente puramente apresentacional -/// — quem decide o status (loading/data/offline) é o cubit que consome a -/// API do BCB; esse widget só sabe desenhar cada um dos 3 estados. -class MetricCard extends StatelessWidget { - final MetricStatus status; - final double? ratePercent; - final double? variationPercent; - final List sparklineData; - final String caption; - final VoidCallback? onTap; - - /// Estado de carregamento — usado enquanto a requisição está em voo. - const MetricCard.loading({super.key}) - : status = MetricStatus.loading, - ratePercent = null, - variationPercent = null, - sparklineData = const [], - caption = '', - onTap = null; - - /// Estado com dado disponível (o fluxo normal). - const MetricCard.data({ - super.key, - required this.ratePercent, - required this.variationPercent, - required this.sparklineData, - required this.caption, - this.onTap, - }) : status = MetricStatus.data; - - /// Estado sem conexão. Se `ratePercent` vier preenchido, mostra o - /// último valor salvo em cache (esmaecido); se vier nulo, mostra um - /// estado vazio com opção de tentar novamente via `onTap`. - const MetricCard.offline({ - super.key, - this.ratePercent, - this.variationPercent, - this.sparklineData = const [], - required this.caption, - this.onTap, - }) : status = MetricStatus.offline; - - MetricTrend get _trend { - final variation = variationPercent ?? 0; - if (variation > 0) return MetricTrend.up; - if (variation < 0) return MetricTrend.down; - return MetricTrend.flat; - } - - String _formatRate(double rate) => rate.toStringAsFixed(2).replaceAll('.', ','); - - String _formatVariation(double variation) { - final sign = variation > 0 ? '+' : ''; - return '$sign${variation.toStringAsFixed(2).replaceAll('.', ',')}%'; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - - switch (status) { - case MetricStatus.loading: - return _LoadingCard(theme: theme); - case MetricStatus.data: - return _DataCard( - theme: theme, - ratePercent: ratePercent!, - variationPercent: variationPercent!, - sparklineData: sparklineData, - caption: caption, - trend: _trend, - formatRate: _formatRate, - formatVariation: _formatVariation, - onTap: onTap, - ); - case MetricStatus.offline: - return _OfflineCard( - theme: theme, - ratePercent: ratePercent, - variationPercent: variationPercent, - sparklineData: sparklineData, - caption: caption, - trend: _trend, - formatRate: _formatRate, - formatVariation: _formatVariation, - onTap: onTap, - ); - } - } -} - -/// Estado de dado disponível — layout original do card. -class _DataCard extends StatelessWidget { - final MetricCardTheme theme; - final double ratePercent; - final double variationPercent; - final List sparklineData; - final String caption; - final MetricTrend trend; - final String Function(double) formatRate; - final String Function(double) formatVariation; - final VoidCallback? onTap; - - const _DataCard({ - required this.theme, - required this.ratePercent, - required this.variationPercent, - required this.sparklineData, - required this.caption, - required this.trend, - required this.formatRate, - required this.formatVariation, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final Color trendColor = switch (trend) { - MetricTrend.up => theme.variationPositiveColor, - MetricTrend.down => theme.variationNegativeColor, - MetricTrend.flat => theme.variationNeutralColor, - }; - - final content = Container( - key: const Key('metric_card_container'), - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all(color: theme.borderColor), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text('Selic atual', style: theme.labelStyle), - const SizedBox(width: 8), - Container( - key: const Key('metric_card_variation_badge'), - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: trendColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatVariation(variationPercent), - style: theme.variationTextStyle - .copyWith(color: trendColor), - ), - ), - ], - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - text: formatRate(ratePercent), - style: theme.valueStyle, - children: [ - TextSpan(text: '% a.a.', style: theme.valueUnitStyle), - ], - ), - ), - ], - ), - ), - SizedBox( - key: const Key('metric_card_sparkline'), - width: 72, - height: 32, - child: CustomPaint( - painter: SparklinePainter( - data: sparklineData, - color: trendColor, - strokeWidth: theme.sparklineStrokeWidth, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Text(caption, style: theme.captionStyle), - ], - ), - ); - - if (onTap == null) return content; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(theme.borderRadius), - child: content, - ); - } -} - -/// Estado offline — mostra o último valor em cache (esmaecido) se houver, -/// ou um estado vazio com "tentar novamente" se não houver nada salvo. -class _OfflineCard extends StatelessWidget { - final MetricCardTheme theme; - final double? ratePercent; - final double? variationPercent; - final List sparklineData; - final String caption; - final MetricTrend trend; - final String Function(double) formatRate; - final String Function(double) formatVariation; - final VoidCallback? onTap; - - const _OfflineCard({ - required this.theme, - required this.ratePercent, - required this.variationPercent, - required this.sparklineData, - required this.caption, - required this.trend, - required this.formatRate, - required this.formatVariation, - this.onTap, - }); - - bool get _hasCachedData => ratePercent != null; - - @override - Widget build(BuildContext context) { - final container = Container( - key: const Key('metric_card_container'), - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all(color: theme.borderColor), - ), - child: _hasCachedData ? _cachedContent() : _emptyContent(), - ); - - if (onTap == null) return container; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(theme.borderRadius), - child: container, - ); - } - - Widget _offlineTag() { - return Container( - key: const Key('metric_card_offline_tag'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: theme.variationNeutralColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.wifi_off, size: 11, color: theme.variationNeutralColor), - const SizedBox(width: 4), - Text( - 'offline', - style: - theme.variationTextStyle.copyWith(color: theme.variationNeutralColor), - ), - ], - ), - ); - } - - Widget _cachedContent() { - // Valor em cache, mas visualmente esmaecido — deixa claro que não é - // o dado mais recente, sem esconder a última informação conhecida. - return Opacity( - opacity: 0.6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text('Selic atual', style: theme.labelStyle), - const SizedBox(width: 8), - _offlineTag(), - ], - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - text: formatRate(ratePercent!), - style: theme.valueStyle, - children: [ - TextSpan(text: '% a.a.', style: theme.valueUnitStyle), - ], - ), - ), - const SizedBox(height: 10), - Text(caption, style: theme.captionStyle), - ], - ), - ); - } - - Widget _emptyContent() { - return Row( - children: [ - Icon(Icons.wifi_off, size: 18, color: theme.variationNeutralColor), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(caption, style: theme.captionStyle), - if (onTap != null) ...[ - const SizedBox(height: 4), - Text( - 'Toque para tentar novamente', - style: theme.variationTextStyle - .copyWith(color: theme.variationNeutralColor), - ), - ], - ], - ), - ), - ], - ); - } -} - -/// Estado de carregamento — placeholders no formato do layout final, -/// com uma animação sutil de pulso pra sinalizar "carregando". -class _LoadingCard extends StatefulWidget { - final MetricCardTheme theme; - - const _LoadingCard({required this.theme}); - - @override - State<_LoadingCard> createState() => _LoadingCardState(); -} - -class _LoadingCardState extends State<_LoadingCard> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(reverse: true); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Widget _bar({required double width, required double height}) { - return Container( - width: width, - height: height, - decoration: BoxDecoration( - color: widget.theme.skeletonColor, - borderRadius: BorderRadius.circular(4), - ), - ); - } - - @override - Widget build(BuildContext context) { - final theme = widget.theme; - - return Container( - key: const Key('metric_card_container'), - width: double.infinity, - padding: theme.padding, - decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: BorderRadius.circular(theme.borderRadius), - border: Border.all(color: theme.borderColor), - ), - child: FadeTransition( - opacity: _controller.drive( - Tween(begin: 0.4, end: 0.85), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _bar(width: 90, height: 12), - const SizedBox(height: 10), - _bar(width: 130, height: 28), - ], - ), - ), - _bar(width: 72, height: 32), - ], - ), - const SizedBox(height: 12), - _bar(width: 180, height: 11), - ], - ), - ), - ); - } -} diff --git a/lib/modules/home/presentation/components/selic_band.dart b/lib/modules/home/presentation/components/selic_band.dart new file mode 100644 index 0000000..ffb937e --- /dev/null +++ b/lib/modules/home/presentation/components/selic_band.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:intl/intl.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; +import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; + +import '../cubit/selic_cubit.dart'; +import 'sparkline_painter.dart'; + +final _rateFormat = NumberFormat('#,##0.00', 'pt_BR'); + +class SelicBand extends StatelessWidget { + static const chartHeight = 40.0; + + final SelicState state; + final VoidCallback onRetry; + final ValueChanged onPeriodChanged; + final DateTime Function() now; + + const SelicBand({ + super.key, + required this.state, + required this.onRetry, + required this.onPeriodChanged, + this.now = DateTime.now, + }); + + @override + Widget build(BuildContext context) { + final metric = state.metric; + + if (metric == null) { + return state is SelicError + ? _Unavailable(onRetry: onRetry) + : const _Skeleton(); + } + + return _MetricView( + metric: metric, + period: state.period, + offline: state.offline, + chart: !state.chartUnavailable + ? _ChartState.ready + : state is SelicLoading + ? _ChartState.loading + : _ChartState.unavailable, + today: now(), + onPeriodChanged: onPeriodChanged, + onRetry: onRetry, + ); + } +} + +enum _ChartState { ready, loading, unavailable } + +class _MetricView extends StatelessWidget { + final Metric metric; + final SelicPeriod period; + final bool offline; + final _ChartState chart; + final DateTime today; + final ValueChanged onPeriodChanged; + final VoidCallback onRetry; + + const _MetricView({ + required this.metric, + required this.period, + required this.offline, + required this.chart, + required this.today, + required this.onPeriodChanged, + required this.onRetry, + }); + + String get _rate => '${_rateFormat.format(metric.anualRate)}%'; + + String get _variation { + final change = metric.variationPercent.abs(); + if (change == 0) return 'Sem mudança em relação à anterior'; + final unit = change < 2 ? 'ponto' : 'pontos'; + return '${_rateFormat.format(change)} $unit em relação à anterior'; + } + + String get _updated { + final date = metric.updatedAt ?? metric.lastDate; + if (date == null || DateUtils.isSameDay(date, today)) { + return 'atualizada hoje'; + } + String two(int value) => value.toString().padLeft(2, '0'); + return 'atualizada em ${two(date.day)}/${two(date.month)}'; + } + + @override + Widget build(BuildContext context) { + final soft = AppColors.of(context).onBandSoft; + final change = metric.variationPercent; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + children: [ + Text( + 'Taxa Selic hoje', + style: AppTextStyles.bandKicker.copyWith(color: Colors.white), + ), + _PeriodSelector(selected: period, onChanged: onPeriodChanged), + ], + ), + const SizedBox(height: AppSpacing.xs), + Opacity( + opacity: offline ? 0.5 : 1, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + _rate, + style: AppTextStyles.bandStatement.copyWith( + color: Colors.white, + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + 'ao ano', + style: AppTextStyles.bandSub.copyWith(color: soft), + ), + ], + ), + ), + ), + Row( + children: [ + if (change != 0) ...[ + AppIcon( + change > 0 ? AppIcons.arrowUp : AppIcons.arrowDown, + size: 16, + color: Colors.white, + ), + const SizedBox(width: AppSpacing.xs), + ], + Expanded( + child: Text( + _variation, + style: AppTextStyles.bandSub.copyWith(color: Colors.white), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: SelicBand.chartHeight, + width: double.infinity, + child: switch (chart) { + _ChartState.ready => Semantics( + image: true, + label: + 'Gráfico da Selic nos últimos ${period.label}, ' + 'valor atual $_rate ao ano', + child: CustomPaint( + painter: SparklinePainter( + data: metric.sparklineData, + color: Colors.white.withValues(alpha: offline ? 0.5 : 1), + strokeWidth: 1.75, + ), + ), + ), + _ChartState.loading => DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(6), + ), + ), + _ChartState.unavailable => Align( + alignment: Alignment.centerLeft, + child: Text( + 'Gráfico de ${period.label} indisponível sem conexão.', + style: AppTextStyles.caption.copyWith(color: soft), + ), + ), + }, + ), + const SizedBox(height: AppSpacing.md), + if (offline) + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.sm, + children: [ + _OfflineTag(color: soft), + Text( + 'Último valor salvo, $_updated.', + style: AppTextStyles.caption.copyWith(color: soft), + ), + _Link(label: 'Atualizar', onTap: onRetry), + ], + ) + else + Text( + 'Fonte: Banco Central, $_updated.', + style: AppTextStyles.caption.copyWith(color: soft), + ), + ], + ); + } +} + +class _PeriodSelector extends StatelessWidget { + final SelicPeriod selected; + final ValueChanged onChanged; + + const _PeriodSelector({required this.selected, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final soft = AppColors.of(context).onBandSoft; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final period in SelicPeriod.values) + Semantics( + button: true, + selected: period == selected, + child: InkWell( + onTap: () => onChanged(period), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Center( + widthFactor: 1, + child: Container( + padding: const EdgeInsets.only(bottom: 2), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: period == selected + ? Colors.white + : Colors.transparent, + width: 1.5, + ), + ), + ), + child: Text( + period.label, + style: AppTextStyles.caption.copyWith( + fontSize: 13, + fontWeight: FontWeight.w700, + color: period == selected ? Colors.white : soft, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _OfflineTag extends StatelessWidget { + final Color color; + + const _OfflineTag({required this.color}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(AppIcons.wifiOff, size: 14, color: color), + const SizedBox(width: 6), + Text( + 'sem conexão', + style: AppTextStyles.caption.copyWith( + fontSize: 13, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ); + } +} + +class _Link extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const _Link({required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Center( + widthFactor: 1, + child: Text( + label, + style: AppTextStyles.caption.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: Colors.white, + ), + ), + ), + ), + ), + ); + } +} + +class _Unavailable extends StatelessWidget { + final VoidCallback onRetry; + + const _Unavailable({required this.onRetry}); + + @override + Widget build(BuildContext context) { + final soft = AppColors.of(context).onBandSoft; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(AppIcons.wifiOff, size: 22, color: soft), + const SizedBox(height: AppSpacing.sm), + Text( + 'Selic indisponível', + style: AppTextStyles.screenTitle.copyWith(color: Colors.white), + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Sem conexão com o Banco Central.', + style: AppTextStyles.bandSub.copyWith(color: soft), + ), + _Link(label: 'Tentar de novo', onTap: onRetry), + ], + ); + } +} + +class _Skeleton extends StatefulWidget { + const _Skeleton(); + + @override + State<_Skeleton> createState() => _SkeletonState(); +} + +class _SkeletonState extends State<_Skeleton> + with SingleTickerProviderStateMixin { + late final _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.disableAnimationsOf(context)) { + _controller + ..stop() + ..value = 0; + } else if (!_controller.isAnimating) { + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Carregando a taxa Selic', + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final color = Colors.white.withValues( + alpha: 0.10 + 0.10 * _controller.value, + ); + Widget block(double width, double height) => Container( + width: width, + height: height, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + block(120, 16), + const SizedBox(height: 12), + block(180, 44), + const SizedBox(height: 10), + block(220, 14), + const SizedBox(height: AppSpacing.md), + block(double.infinity, SelicBand.chartHeight), + const SizedBox(height: AppSpacing.md), + block(200, 12), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/home/presentation/cubit/selic_cubit.dart b/lib/modules/home/presentation/cubit/selic_cubit.dart index 5dc7829..2a9da77 100644 --- a/lib/modules/home/presentation/cubit/selic_cubit.dart +++ b/lib/modules/home/presentation/cubit/selic_cubit.dart @@ -1,25 +1,43 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:real_calc/core/seed_works/error.dart'; import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; import 'package:real_calc/modules/metrics/use_cases/selic/i_selic_use_case.dart'; part 'selic_state.dart'; class SelicCubit extends Cubit { final ISelicUseCase _useCase; + final _metrics = {}; + var _request = 0; - SelicCubit(this._useCase) : super(SelicInitial()); + SelicCubit(this._useCase) : super(const SelicInitial()); - Future load() async { - final previousMetric = state.metric; - emit(SelicLoading(previousMetric)); + Future load() => _fetch(state.period); - final result = await _useCase.getSelicRate(); - result.fold( - (error) => emit(SelicError(error, previousMetric)), - (metric) => emit(SelicLoaded(metric)), - ); + Future retry() => load(); + + Future changePeriod(SelicPeriod period) async { + if (period == state.period) return; + await _fetch(period); } - Future retry() => load(); + Future _fetch(SelicPeriod period) async { + final request = ++_request; + final own = _metrics[period]; + final shown = own ?? state.metric; + final foreign = own == null && shown != null; + emit(SelicLoading(shown, period, foreign)); + + final result = await _useCase.getSelic(period); + // Uma troca de período mais recente já está a caminho. + if (isClosed || request != _request) return; + + result.fold((error) => emit(SelicError(error, shown, period, foreign)), ( + metric, + ) { + _metrics[period] = metric; + emit(SelicLoaded(metric, period)); + }); + } } diff --git a/lib/modules/home/presentation/cubit/selic_state.dart b/lib/modules/home/presentation/cubit/selic_state.dart index d672e5e..751433e 100644 --- a/lib/modules/home/presentation/cubit/selic_state.dart +++ b/lib/modules/home/presentation/cubit/selic_state.dart @@ -2,8 +2,18 @@ part of 'selic_cubit.dart'; sealed class SelicState { final Metric? metric; + final SelicPeriod period; - const SelicState([this.metric]); + /// `metric` é de outro período: a taxa vale, mas o gráfico não. + final bool chartUnavailable; + + const SelicState([ + this.metric, + this.period = SelicPeriod.months12, + this.chartUnavailable = false, + ]); + + bool get offline => this is SelicError || (metric?.offline ?? false); } final class SelicInitial extends SelicState { @@ -11,15 +21,20 @@ final class SelicInitial extends SelicState { } final class SelicLoading extends SelicState { - const SelicLoading([super.metric]); + const SelicLoading([super.metric, super.period, super.chartUnavailable]); } final class SelicLoaded extends SelicState { - const SelicLoaded(super.metric); + const SelicLoaded(Metric super.metric, [super.period]); } final class SelicError extends SelicState { final ErrorMessages error; - const SelicError(this.error, [super.metric]); -} \ No newline at end of file + const SelicError( + this.error, [ + super.metric, + super.period, + super.chartUnavailable, + ]); +} diff --git a/lib/modules/home/presentation/page/page.dart b/lib/modules/home/presentation/page/page.dart index 13b8ebe..d2f441b 100644 --- a/lib/modules/home/presentation/page/page.dart +++ b/lib/modules/home/presentation/page/page.dart @@ -1,139 +1,164 @@ import 'package:flutter/material.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:real_calc/core/routes/app_routes.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; -import 'package:real_calc/modules/home/domain/enum/menu_card_variant.dart'; -import '../../../../core/themes/extensions/home_page_theme.dart'; -import '../components/menu_card.dart'; -import '../components/metric_card.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; +import 'package:real_calc/core/widgets/app_logo.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/theme_toggle_button.dart'; + +import '../components/calculator_list_item.dart'; +import '../components/selic_band.dart'; import '../cubit/greeting_cubit.dart'; import '../cubit/selic_cubit.dart'; class HomePage extends StatelessWidget { - const HomePage({super.key}); - - BlocBuilder _buildGreetingWidget( - BuildContext context, - ) { - return BlocBuilder( - builder: (context, greeting) => - TitleWidget(title: greeting, subtitle: 'O que vamos calcular?', invert: true), - ); - } + static const footer = + 'Mesma metodologia da Calculadora do Cidadão, do Banco Central.'; - BlocBuilder _buildMetricCard(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is SelicLoading || state is SelicInitial) { - return const MetricCard.loading(); - } + static const calculators = [ + ( + kind: CalculatorKind.financing, + icon: AppIcons.bank, + title: 'Financiamento', + description: 'Prestação, prazo ou taxa', + route: AppRoutes.financing, + ), + ( + kind: CalculatorKind.deposits, + icon: AppIcons.piggyBank, + title: 'Depósitos Regulares', + description: 'Quanto você acumula todo mês', + route: AppRoutes.deposits, + ), + ( + kind: CalculatorKind.futureValue, + icon: AppIcons.trendingUp, + title: 'Valor Futuro', + description: 'Quanto rende um valor aplicado', + route: AppRoutes.futureValue, + ), + ( + kind: CalculatorKind.correction, + icon: AppIcons.cycle, + title: 'Correção de Valores', + description: 'Pela inflação, juros ou poupança', + route: AppRoutes.correction, + ), + ]; - final metric = state.metric; - if (state is SelicLoaded && metric != null) { - return MetricCard.data( - ratePercent: metric.anualRate, - variationPercent: metric.variationPercent, - sparklineData: metric.sparklineData, - caption: 'Atualizada pelo Banco Central', - onTap: BlocProvider.of(context).retry, - ); - } + const HomePage({super.key}); - return MetricCard.offline( - ratePercent: metric?.anualRate, - variationPercent: metric?.variationPercent, - sparklineData: metric?.sparklineData ?? const [], - caption: state is SelicError - ? state.error.message - : 'Não foi possível carregar a taxa Selic', - onTap: BlocProvider.of(context).retry, - ); - }, - ); - } + @override + Widget build(BuildContext context) { + final colors = AppColors.of(context); - Widget _buildOpitionForms(BuildContext context, HomePageTheme theme) { - return Column( - spacing: AppSpacing.md, - children: [ - MenuCard( - icon: Icons.account_balance, - title: 'Financiamento', - description: 'Prestações com juros compostos', - cardColor: theme.cardColor, - iconColor: theme.iconContainerColor, - onTap: () => Modular.to.pushNamed(AppRoutes.financing), - variant: MenuCardVariant.featured, - ), - SizedBox( - height: 100, - child: Row( - children: [ - Expanded( - child: MenuCard( - icon: Icons.account_balance, - title: 'Depósitos Regulares', - description: 'Prestações com juros compostos', - cardColor: theme.cardColor, - iconColor: theme.iconContainerColor, - onTap: () => Modular.to.pushNamed(AppRoutes.deposits), - variant: MenuCardVariant.standard, + return Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnnotatedRegion( + value: CalculatorBand.statusBarStyle, + child: ColoredBox( + color: colors.bandHome, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen - 10, + AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Row( + children: [AppLogo(), Spacer(), ThemeToggleButton()], + ), + const SizedBox(height: AppSpacing.md), + Padding( + padding: const EdgeInsets.only(right: 10), + child: BlocBuilder( + builder: (context, state) => SelicBand( + state: state, + onRetry: BlocProvider.of(context).retry, + onPeriodChanged: BlocProvider.of( + context, + ).changePeriod, + ), + ), + ), + ], + ), ), ), - VerticalDivider( - color: theme.lineColor, - thickness: 1, - width: AppSpacing.md, + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.lg, + AppSpacing.screen, + AppSpacing.lg, ), - Expanded( - child: MenuCard( - icon: Icons.bar_chart, - title: 'Valor Futuro', - description: 'Prestações com juros compostos', - cardColor: theme.cardColor, - iconColor: theme.iconContainerColor, - onTap: () => Modular.to.pushNamed(AppRoutes.futureValue), - variant: MenuCardVariant.standard, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BlocBuilder( + builder: (context, greeting) => Text( + greeting, + style: AppTextStyles.body.copyWith( + color: colors.textSecondary, + ), + ), + ), + Semantics( + header: true, + child: Text( + 'O que vamos calcular hoje?', + style: AppTextStyles.screenTitle.copyWith( + color: colors.textPrimary, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.hairline)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final calculator in calculators) + CalculatorListItem( + kind: calculator.kind, + iconAsset: calculator.icon, + title: calculator.title, + description: calculator.description, + onTap: () => Modular.to.pushNamed(calculator.route), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + footer, + style: AppTextStyles.caption.copyWith( + color: colors.textHint, + ), + ), + ], ), - ], - ), - ), - MenuCard( - icon: Icons.trending_up, - title: 'Correção de Valores', - description: 'Atualize valores por índices de inflação', - cardColor: theme.cardColor, - iconColor: theme.iconContainerColor, - onTap: () => Modular.to.pushNamed(AppRoutes.correction), - variant: MenuCardVariant.standard, - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - final spacing = 4; - - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: AppSpacing.xl, - children: [ - SizedBox(height: AppSpacing.xs- spacing), - _buildGreetingWidget(context), - _buildMetricCard(context), - _buildOpitionForms(context, theme), - ], + ), ), - ), + ], ), ); } diff --git a/lib/modules/metrics/domain/entites/metric.dart b/lib/modules/metrics/domain/entites/metric.dart index 8681c61..34f6288 100644 --- a/lib/modules/metrics/domain/entites/metric.dart +++ b/lib/modules/metrics/domain/entites/metric.dart @@ -1,13 +1,37 @@ import 'package:real_calc/core/seed_works/entity.dart'; -class Metric extends Entity{ +class Metric extends Entity { final double anualRate; final double variationPercent; final List sparklineData; + final List sparklineDates; + final DateTime? lastDate; + final bool offline; Metric({ - required this.anualRate, - required this.variationPercent, - required this.sparklineData + required this.anualRate, + required this.variationPercent, + required this.sparklineData, + this.sparklineDates = const [], + this.lastDate, + this.offline = false, + super.updatedAt, }); -} \ No newline at end of file + + Metric copyWith({ + List? sparklineData, + List? sparklineDates, + bool? offline, + DateTime? updatedAt, + }) { + return Metric( + anualRate: anualRate, + variationPercent: variationPercent, + sparklineData: sparklineData ?? this.sparklineData, + sparklineDates: sparklineDates ?? this.sparklineDates, + lastDate: lastDate, + offline: offline ?? this.offline, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/modules/metrics/domain/enum/selic_period.dart b/lib/modules/metrics/domain/enum/selic_period.dart new file mode 100644 index 0000000..776d393 --- /dev/null +++ b/lib/modules/metrics/domain/enum/selic_period.dart @@ -0,0 +1,9 @@ +enum SelicPeriod { + months12('12 meses', 12), + years5('5 anos', 60); + + final String label; + final int months; + + const SelicPeriod(this.label, this.months); +} diff --git a/lib/modules/metrics/domain/repositories/selic/i_selic_cache_repository.dart b/lib/modules/metrics/domain/repositories/selic/i_selic_cache_repository.dart new file mode 100644 index 0000000..8f97d36 --- /dev/null +++ b/lib/modules/metrics/domain/repositories/selic/i_selic_cache_repository.dart @@ -0,0 +1,7 @@ +import '../../entites/metric.dart'; +import '../../enum/selic_period.dart'; + +abstract class ISelicCacheRepository { + Future read(SelicPeriod period); + Future save(SelicPeriod period, Metric metric); +} diff --git a/lib/modules/metrics/infrastructure/repositories/selic_cache_repository.dart b/lib/modules/metrics/infrastructure/repositories/selic_cache_repository.dart new file mode 100644 index 0000000..ba147bb --- /dev/null +++ b/lib/modules/metrics/infrastructure/repositories/selic_cache_repository.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../domain/entites/metric.dart'; +import '../../domain/enum/selic_period.dart'; +import '../../domain/repositories/selic/i_selic_cache_repository.dart'; + +class SelicCacheRepository implements ISelicCacheRepository { + static String keyFor(SelicPeriod period) => 'selic_cache_${period.name}'; + + final Future Function() _preferences; + + SelicCacheRepository([this._preferences = SharedPreferences.getInstance]); + + @override + Future read(SelicPeriod period) async { + final raw = (await _preferences()).getString(keyFor(period)); + if (raw == null) return null; + try { + return _fromJson(jsonDecode(raw) as Map); + } on Object { + return null; + } + } + + @override + Future save(SelicPeriod period, Metric metric) async { + await (await _preferences()).setString( + keyFor(period), + jsonEncode(_toJson(metric)), + ); + } + + Map _toJson(Metric metric) => { + 'anualRate': metric.anualRate, + 'variationPercent': metric.variationPercent, + 'sparklineData': metric.sparklineData, + 'sparklineDates': [ + for (final date in metric.sparklineDates) date.toIso8601String(), + ], + 'lastDate': metric.lastDate?.toIso8601String(), + 'updatedAt': metric.updatedAt?.toIso8601String(), + }; + + Metric _fromJson(Map json) { + DateTime? date(Object? value) => + value is String ? DateTime.parse(value) : null; + + return Metric( + anualRate: (json['anualRate'] as num).toDouble(), + variationPercent: (json['variationPercent'] as num).toDouble(), + sparklineData: [ + for (final value in json['sparklineData'] as List) + (value as num).toDouble(), + ], + sparklineDates: [ + for (final value in json['sparklineDates'] as List) + DateTime.parse(value as String), + ], + lastDate: date(json['lastDate']), + updatedAt: date(json['updatedAt']), + ); + } +} diff --git a/lib/modules/metrics/infrastructure/repositories/selic_repository.dart b/lib/modules/metrics/infrastructure/repositories/selic_repository.dart index 751eb80..544edb8 100644 --- a/lib/modules/metrics/infrastructure/repositories/selic_repository.dart +++ b/lib/modules/metrics/infrastructure/repositories/selic_repository.dart @@ -11,10 +11,11 @@ class SelicRepository implements ISelicNetworkOnlyRepository { SelicRepository(this._selicDatasource); @override - Future>> getSelicRate() async { + Future>> + getSelicRate() async { final dataFinal = DateTime.now(); final dataInicial = DateTime( - dataFinal.year - 4, + dataFinal.year - 1, dataFinal.month, dataFinal.day, ); @@ -24,7 +25,8 @@ class SelicRepository implements ISelicNetworkOnlyRepository { ); return response.fold( - (error) => FailureResult>(error), + (error) => + FailureResult>(error), (data) => SuccessResult>( data .whereType>() @@ -36,7 +38,8 @@ class SelicRepository implements ISelicNetworkOnlyRepository { // Método adicional para buscar por período específico @override - Future>> getSelicRateByPeriod({ + Future>> + getSelicRateByPeriod({ required DateTime dataInicial, required DateTime dataFinal, }) async { @@ -45,7 +48,8 @@ class SelicRepository implements ISelicNetworkOnlyRepository { ); return response.fold( - (error) => FailureResult>(error), + (error) => + FailureResult>(error), (data) => SuccessResult>( data .whereType>() @@ -60,4 +64,4 @@ class SelicRepository implements ISelicNetworkOnlyRepository { final month = date.month.toString().padLeft(2, '0'); return '$day/$month/${date.year}'; } -} \ No newline at end of file +} diff --git a/lib/modules/metrics/module.dart b/lib/modules/metrics/module.dart index bcec240..709a433 100644 --- a/lib/modules/metrics/module.dart +++ b/lib/modules/metrics/module.dart @@ -1,29 +1,33 @@ - import 'package:flutter_modular/flutter_modular.dart'; - import 'package:real_calc/core/module.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:real_calc/core/module.dart'; - import 'domain/repositories/selic/i_selic_network_only_repository.dart'; - import 'infrastructure/repositories/selic_repository.dart'; - import 'use_cases/selic/i_selic_use_case.dart'; - import 'use_cases/selic/selic_use_case.dart'; - import 'use_cases/selic/selic_validation.dart'; +import 'domain/repositories/selic/i_selic_cache_repository.dart'; +import 'domain/repositories/selic/i_selic_network_only_repository.dart'; +import 'infrastructure/repositories/selic_cache_repository.dart'; +import 'infrastructure/repositories/selic_repository.dart'; +import 'use_cases/selic/i_selic_use_case.dart'; +import 'use_cases/selic/selic_use_case.dart'; +import 'use_cases/selic/selic_validation.dart'; - class MetricsModule extends Module { - @override - List get imports => [CoreModule()]; +class MetricsModule extends Module { + @override + List get imports => [CoreModule()]; - @override - void binds(Injector i) { - i.addLazySingleton(SelicRepository.new); - i.addLazySingleton(SelicValidation.new); - } + @override + void binds(Injector i) { + i.addLazySingleton(SelicRepository.new); + i.addLazySingleton(SelicValidation.new); + i.addLazySingleton(() => SelicCacheRepository()); + } - @override - void exportedBinds(Injector i) { - i.addLazySingleton( - () => SelicUseCase( - i.get(), - i.get(), - ), - ); - } + @override + void exportedBinds(Injector i) { + i.addLazySingleton( + () => SelicUseCase( + i.get(), + i.get(), + i.get(), + ), + ); } +} diff --git a/lib/modules/metrics/use_cases/selic/i_selic_use_case.dart b/lib/modules/metrics/use_cases/selic/i_selic_use_case.dart index b12ad39..300a042 100644 --- a/lib/modules/metrics/use_cases/selic/i_selic_use_case.dart +++ b/lib/modules/metrics/use_cases/selic/i_selic_use_case.dart @@ -2,8 +2,12 @@ import 'package:real_calc/core/seed_works/result.dart'; import '../../../../core/seed_works/error.dart'; import '../../domain/entites/metric.dart'; +import '../../domain/enum/selic_period.dart'; abstract class ISelicUseCase { + /// Tenta a rede e, se falhar, devolve o último valor salvo do período + /// marcado como `offline`. + Future> getSelic(SelicPeriod period); Future> getSelicRate(); Future> getSelicRateByPeriod({ required DateTime dataInicial, diff --git a/lib/modules/metrics/use_cases/selic/selic_use_case.dart b/lib/modules/metrics/use_cases/selic/selic_use_case.dart index c0c1437..a9444e4 100644 --- a/lib/modules/metrics/use_cases/selic/selic_use_case.dart +++ b/lib/modules/metrics/use_cases/selic/selic_use_case.dart @@ -1,8 +1,12 @@ +import 'dart:math'; + import 'package:real_calc/core/seed_works/result.dart'; import '../../../../core/seed_works/error.dart'; import '../../../../shared/network/response/selic_bc_response_network.dart'; import '../../domain/entites/metric.dart'; +import '../../domain/enum/selic_period.dart'; +import '../../domain/repositories/selic/i_selic_cache_repository.dart'; import '../../domain/repositories/selic/i_selic_network_only_repository.dart'; import 'i_selic_use_case.dart'; import 'selic_validation.dart'; @@ -10,8 +14,76 @@ import 'selic_validation.dart'; class SelicUseCase implements ISelicUseCase { final ISelicNetworkOnlyRepository _selicRepository; final SelicValidation _validation; + final ISelicCacheRepository _cache; + final DateTime Function() _now; + + static const maxChartPoints = 60; - SelicUseCase(this._selicRepository, this._validation); + SelicUseCase( + this._selicRepository, + this._validation, + this._cache, { + DateTime Function() now = DateTime.now, + }) : _now = now; + + @override + Future> getSelic(SelicPeriod period) async { + final now = _now(); + final result = switch (period) { + SelicPeriod.months12 => await getSelicRate(), + SelicPeriod.years5 => await getSelicRateByPeriod( + dataInicial: DateTime(now.year - 5, now.month, now.day), + dataFinal: now, + ), + }; + + return result.fold( + (error) async { + final cached = await _cache.read(period); + return cached == null + ? FailureResult(error) + : SuccessResult( + cached.copyWith(offline: true), + ); + }, + (metric) async { + final reduced = period == SelicPeriod.years5 + ? _onePointPerMonth(metric) + : metric; + final fresh = reduced.copyWith(updatedAt: now); + try { + await _cache.save(period, fresh); + } on Object { + // Sem cache o app só perde o modo offline; o valor da rede segue. + } + return SuccessResult(fresh); + }, + ); + } + + Metric _onePointPerMonth(Metric metric) { + final values = metric.sparklineData; + final dates = metric.sparklineDates; + if (dates.length != values.length) { + return metric.copyWith( + sparklineData: values + .skip(max(0, values.length - maxChartPoints)) + .toList(), + sparklineDates: const [], + ); + } + + final byMonth = {}; + for (var i = 0; i < dates.length; i++) { + byMonth[dates[i].year * 12 + dates[i].month] = i; + } + final kept = byMonth.values.toList()..sort(); + final last = kept.skip(max(0, kept.length - maxChartPoints)).toList(); + return metric.copyWith( + sparklineData: [for (final i in last) values[i]], + sparklineDates: [for (final i in last) dates[i]], + ); + } @override Future> getSelicRate() async { @@ -51,26 +123,37 @@ class SelicUseCase implements ISelicUseCase { final anualRate = double.tryParse(data.last.valor ?? '') ?? 0.0; final variationPercent = _calculateVariation(data); final sparklineData = data.map((e) => double.parse(e.valor!)).toList(); + final dates = data.map((e) => _parseDate(e.data)).toList(); return Metric( anualRate: anualRate, variationPercent: variationPercent, sparklineData: sparklineData, + sparklineDates: dates.contains(null) ? const [] : dates.cast(), + lastDate: dates.isEmpty ? null : dates.last, ); } + DateTime? _parseDate(String? value) { + final parts = value?.split('/'); + if (parts == null || parts.length != 3) return null; + final [day, month, year] = parts.map(int.tryParse).toList(); + if (day == null || month == null || year == null) return null; + return DateTime(year, month, day); + } + double _calculateVariation(List values) { if (values.length < 2) return 0.0; - - final currentValue = double.tryParse(values.last.valor!) ?? 0.0; - + + final currentValue = double.tryParse(values.last.valor!) ?? 0.0; + for (int i = values.length - 2; i >= 0; i--) { final previousValue = double.tryParse(values[i].valor!) ?? 0.0; if (previousValue != currentValue) { return double.parse((currentValue - previousValue).toStringAsFixed(2)); } } - + return 0.0; } } diff --git a/lib/modules/value_correction/domain/enum/correction_index.dart b/lib/modules/value_correction/domain/enum/correction_index.dart index 604b089..b4f4e46 100644 --- a/lib/modules/value_correction/domain/enum/correction_index.dart +++ b/lib/modules/value_correction/domain/enum/correction_index.dart @@ -4,7 +4,8 @@ import 'series_kind.dart'; enum CorrectionIndex { ipca( - label: 'IPCA (IBGE)', + label: 'IPCA', + source: 'IBGE', group: CorrectionGroup.inflation, sgsCode: 433, kind: SeriesKind.monthlyVariation, @@ -13,16 +14,18 @@ enum CorrectionIndex { fromMonth: 1, ), inpc( - label: 'INPC (IBGE)', - sgsCode: 188, + label: 'INPC', + source: 'IBGE', group: CorrectionGroup.inflation, + sgsCode: 188, kind: SeriesKind.monthlyVariation, granularity: DateGranularity.month, fromYear: 1979, fromMonth: 7, ), ipcaE( - label: 'IPCA-E (IBGE)', + label: 'IPCA-E', + source: 'IBGE', sgsCode: 10764, group: CorrectionGroup.inflation, kind: SeriesKind.monthlyVariation, @@ -31,7 +34,8 @@ enum CorrectionIndex { fromMonth: 1, ), igpDi( - label: 'IGP-DI (FGV)', + label: 'IGP-DI', + source: 'FGV', sgsCode: 190, group: CorrectionGroup.inflation, kind: SeriesKind.monthlyVariation, @@ -40,17 +44,18 @@ enum CorrectionIndex { fromMonth: 2, ), igpM( - label: 'IGP-M (FGV)', + label: 'IGP-M', + source: 'FGV', sgsCode: 28655, group: CorrectionGroup.inflation, kind: SeriesKind.monthlyVariation, granularity: DateGranularity.month, - fromYear: 2021, - fromMonth: 4, - fromDay: 12, + fromYear: 1989, + fromMonth: 6, ), ipcBrasil( - label: 'IPC-Brasil (FGV)', + label: 'IPC-Brasil', + source: 'FGV', sgsCode: 191, group: CorrectionGroup.inflation, kind: SeriesKind.monthlyVariation, @@ -59,7 +64,8 @@ enum CorrectionIndex { fromMonth: 1, ), ipcSp( - label: 'IPC-SP (FIPE)', + label: 'IPC-SP', + source: 'FIPE', sgsCode: 193, group: CorrectionGroup.inflation, kind: SeriesKind.monthlyVariation, @@ -69,6 +75,7 @@ enum CorrectionIndex { ), cdi( label: 'CDI', + source: 'B3', sgsCode: 12, group: CorrectionGroup.interest, kind: SeriesKind.dailyRate, @@ -78,6 +85,7 @@ enum CorrectionIndex { ), selic( label: 'Selic', + source: 'Banco Central', sgsCode: 11, group: CorrectionGroup.interest, kind: SeriesKind.dailyRate, @@ -88,6 +96,7 @@ enum CorrectionIndex { ), tr( label: 'TR', + source: 'Banco Central', sgsCode: 226, group: CorrectionGroup.interest, kind: SeriesKind.periodRate, @@ -97,6 +106,7 @@ enum CorrectionIndex { ), poupancaNova( label: 'Poupança Nova', + source: 'Banco Central', sgsCode: 195, group: CorrectionGroup.savings, kind: SeriesKind.periodRate, @@ -107,6 +117,7 @@ enum CorrectionIndex { ), poupancaVelha( label: 'Poupança Velha', + source: 'Banco Central', sgsCode: 25, group: CorrectionGroup.savings, kind: SeriesKind.periodRate, @@ -116,6 +127,7 @@ enum CorrectionIndex { ), taxaLegal( label: 'Taxa Legal', + source: 'Banco Central', sgsCode: 29543, group: CorrectionGroup.interest, kind: SeriesKind.simpleMonthlyRate, @@ -127,6 +139,7 @@ enum CorrectionIndex { const CorrectionIndex({ required this.label, + required this.source, required this.group, required this.sgsCode, required this.kind, @@ -137,6 +150,7 @@ enum CorrectionIndex { }); final String label; + final String source; final CorrectionGroup group; final int sgsCode; final SeriesKind kind; @@ -154,6 +168,9 @@ enum CorrectionIndex { : '${fromDay.toString().padLeft(2, '0')}/' '${fromMonth.toString().padLeft(2, '0')}/$fromYear'; + String get sinceMonthLabel => + '${fromMonth.toString().padLeft(2, '0')}/$fromYear'; + String get availabilityMessage => 'O índice $label possui dados a partir de $availableFromInputLabel.'; diff --git a/lib/modules/value_correction/domain/services/value_correction_sharer.dart b/lib/modules/value_correction/domain/services/value_correction_sharer.dart deleted file mode 100644 index 36648f4..0000000 --- a/lib/modules/value_correction/domain/services/value_correction_sharer.dart +++ /dev/null @@ -1,3 +0,0 @@ -abstract interface class ValueCorrectionSharer { - Future share(String text); -} diff --git a/lib/modules/value_correction/infrastructure/services/share_plus_value_correction_sharer.dart b/lib/modules/value_correction/infrastructure/services/share_plus_value_correction_sharer.dart deleted file mode 100644 index df4e4ef..0000000 --- a/lib/modules/value_correction/infrastructure/services/share_plus_value_correction_sharer.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:share_plus/share_plus.dart'; - -import '../../domain/services/value_correction_sharer.dart'; - - -class SharePlusValueCorrectionSharer implements ValueCorrectionSharer { - @override - Future share(String text) async { - await SharePlus.instance.share(ShareParams(text: text)); - } -} diff --git a/lib/modules/value_correction/module.dart b/lib/modules/value_correction/module.dart index a65bab2..47cbb2d 100644 --- a/lib/modules/value_correction/module.dart +++ b/lib/modules/value_correction/module.dart @@ -1,16 +1,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:real_calc/core/module.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/services/share_plus_result_sharer.dart'; import '../../core/routes/app_routes.dart'; -import 'domain/entites/value_correction.dart'; import 'domain/enum/correction_index.dart'; import 'domain/repositories/i_correction_series_repository.dart'; -import 'domain/services/value_correction_sharer.dart'; import 'domain/validation/i_value_correction_validation.dart'; import 'domain/validation/value_correction_validation.dart'; import 'infrastructure/repositories/sgs_repository.dart'; -import 'infrastructure/services/share_plus_value_correction_sharer.dart'; import 'presentation/cubit/value_correction/value_correction_cubit.dart'; import 'presentation/cubit/value_correction/request_parsing/i_value_correction_request_parser.dart'; import 'presentation/cubit/value_correction/request_parsing/value_correction_request_parser.dart'; @@ -24,7 +23,6 @@ import 'presentation/cubit/forms/validation/correction_form_validator.dart'; import 'presentation/cubit/forms/validation/i_correction_form_validator.dart'; import 'presentation/cubit/forms/value_correction_form_cubit.dart'; import 'presentation/pages/value_correction_page.dart'; -import 'presentation/pages/value_correction_result_page.dart'; import 'use_cases/calculate_value_correction.dart'; import 'use_cases/correction/data_preparation/correction_data_preparer.dart'; import 'use_cases/correction/data_preparation/i_correction_data_preparer.dart'; @@ -69,9 +67,7 @@ class ValueCorrectionModule extends Module { i.addLazySingleton(CorrectionDataPreparer.new); i.addLazySingleton(SgsRepository.new); i.addLazySingleton(CalculateValueCorrection.new); - i.addLazySingleton( - SharePlusValueCorrectionSharer.new, - ); + i.addLazySingleton(SharePlusResultSharer.new); i.addLazySingleton(ValueCorrectionFormCubit.new); i.addLazySingleton(ValueCorrectionCubit.new); } @@ -92,12 +88,5 @@ class ValueCorrectionModule extends Module { child: const ValueCorrectionPage(), ), ); - r.child( - '/${AppRoutes.correctionResultSegment}', - child: (_) { - final result = Modular.args.data as ValueCorrection; - return ValueCorrectionResultPage(result: result); - }, - ); } } diff --git a/lib/modules/value_correction/presentation/pages/value_correction_page.dart b/lib/modules/value_correction/presentation/pages/value_correction_page.dart index 1cdd0b8..56e43db 100644 --- a/lib/modules/value_correction/presentation/pages/value_correction_page.dart +++ b/lib/modules/value_correction/presentation/pages/value_correction_page.dart @@ -1,223 +1,412 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart' as modular; - -import '../../../../core/themes/spacing.dart'; -import '../../../../core/utils/date_input_formatter.dart'; -import '../../../../core/utils/decimal_input_formatter.dart'; -import '../../../../core/utils/month_year_input_formatter.dart'; -import '../../../../core/widgets/help_card.dart'; -import '../../../../core/widgets/input_forms_result_card.dart'; -import '../../../../core/widgets/options_bottom_forms.dart'; -import '../../../../core/widgets/page_header.dart'; -import '../../../../core/widgets/title_widget.dart'; -import '../../../../core/widgets/types/input_field_state.dart'; -import '../../../../core/widgets/error_banner.dart'; -import '../../../../core/widgets/warning_banner.dart'; -import '../../../../core/routes/app_routes.dart'; +import 'package:real_calc/core/errors/messages.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; +import 'package:real_calc/core/services/share_plus_result_sharer.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; +import 'package:real_calc/core/utils/date_input_formatter.dart'; +import 'package:real_calc/core/utils/decimal_input_formatter.dart'; +import 'package:real_calc/core/utils/month_year_input_formatter.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/field_list/field_list.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/form_notice.dart'; +import 'package:real_calc/core/widgets/help_sheet/help_sheet.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; +import 'package:real_calc/core/widgets/types/input_field_state.dart'; + +import '../../domain/entites/value_correction.dart'; import '../../domain/enum/correction_index.dart'; import '../../domain/enum/date_granularity.dart'; +import '../cubit/forms/date_parsing/correction_form_date_parser.dart'; +import '../cubit/forms/value_correction_form_cubit.dart'; +import '../cubit/forms/value_correction_form_state.dart'; import '../cubit/value_correction/value_correction_cubit.dart'; import '../cubit/value_correction/value_correction_state.dart'; -import '../../presentation/cubit/forms/value_correction_form_cubit.dart'; -import '../../presentation/cubit/forms/value_correction_form_state.dart'; +import '../utils/correction_result_summary.dart'; +import '../utils/correction_statement.dart'; +import '../utils/value_correction_share_text.dart'; import '../widgets/correction_index_picker.dart'; -class ValueCorrectionPage extends StatelessWidget { - const ValueCorrectionPage({super.key}); +typedef _Inputs = ({ + CorrectionIndex? index, + String initial, + String end, + String percentage, + String value, +}); + +class ValueCorrectionPage extends StatefulWidget { + static const title = 'Correção de Valores'; + static const outOfRangeText = 'fora do período'; + + final ResultSharer sharer; + + const ValueCorrectionPage({ + super.key, + this.sharer = const SharePlusResultSharer(), + }); @override - Widget build(BuildContext context) { - final formCubit = WatchContext(context).watch(); - final formState = formCubit.state; - final calculationState = WatchContext( + State createState() => _ValueCorrectionPageState(); +} + +class _ValueCorrectionPageState extends State { + final _dateParser = CorrectionFormDateParser(); + _Inputs? _calculatedInputs; + + ValueCorrectionFormCubit get _form => + BlocProvider.of(context); + + ValueCorrectionCubit get _calculation => + BlocProvider.of(context); + + _Inputs get _inputs => ( + index: _form.state.index, + initial: _form.initialDate.text, + end: _form.finalDate.text, + percentage: _form.percentage.text, + value: _form.value.text, + ); + + void _calculate() { + final index = _form.state.index; + if (!_form.state.canCalculate || index == null) return; + FocusScope.of(context).unfocus(); + _calculatedInputs = _inputs; + _calculation.calculate( + index: index, + initialDate: _form.initialDate.text, + finalDate: _form.finalDate.text, + percentage: _form.percentage.text, + value: _form.value.text, + ); + } + + void _clear() { + final snapshot = _inputs; + final hadResult = _calculation.state is ValueCorrectionCalculated; + FocusScope.of(context).unfocus(); + _form + ..clear() + ..setIndex(snapshot.index ?? CorrectionIndex.ipca); + _calculation.reset(); + _calculatedInputs = null; + + showFieldsClearedSnackBar( context, - ).watch().state; + onUndo: () { + _form + ..setIndex(snapshot.index ?? CorrectionIndex.ipca) + ..initialDate.text = snapshot.initial + ..finalDate.text = snapshot.end + ..percentage.text = snapshot.percentage + ..value.text = snapshot.value; + if (hadResult) _calculate(); + }, + ); + } - final dateInputFormatter = - formState.dateGranularity == DateGranularity.month - ? MonthYearInputFormatter() - : DateInputFormatter(); - final indexController = TextEditingController( - text: formState.index?.label ?? CorrectionIndex.ipca.label, + void _openIndexPicker(CorrectionIndex? selected) { + showCorrectionIndexPicker( + context, + selectedIndex: selected, + onSelected: _form.setIndex, ); + } - void runCalculation(CorrectionIndex index) { - ReadContext(context).read().calculate( - index: index, - initialDate: formCubit.initialDate.text, - finalDate: formCubit.finalDate.text, - percentage: formCubit.percentage.text, - value: formCubit.value.text, - ); - } + bool _isBeforeSeries(String text, CorrectionIndex index) { + final date = _dateParser.parse(text, index.granularity); + return date != null && date.isBefore(index.minimumInputDate); + } - void calculate() { - final currentFormState = formCubit.state; - final index = currentFormState.index; - if (!currentFormState.canCalculate || index == null) return; - runCalculation(index); + String _supportText(CorrectionIndex index) { + final initial = _dateParser.parse( + _form.initialDate.text, + index.granularity, + ); + final end = _dateParser.parse(_form.finalDate.text, index.granularity); + if (initial == null || + end == null || + initial.isAfter(end) || + initial.isBefore(index.minimumInputDate)) { + return index.sinceSentence; } + return '${correctionDuration(index, initial, end)}. ' + '${index.sinceSentence}'; + } - void retryCalculation() { - final index = formCubit.state.index; - if (index == null) return; - runCalculation(index); - } + @override + Widget build(BuildContext context) { + return CalculatorScope( + kind: CalculatorKind.correction, + child: BlocBuilder( + builder: (context, formState) => + BlocBuilder( + builder: (context, calculation) => + _buildPage(context, formState, calculation), + ), + ), + ); + } - void clear() { - formCubit.clear(); - ReadContext(context).read().reset(); - } + Widget _buildPage( + BuildContext context, + ValueCorrectionFormState formState, + ValueCorrectionState calculation, + ) { + final index = formState.index ?? CorrectionIndex.ipca; + final isCurrent = _calculatedInputs == _inputs; + final isLoading = calculation is ValueCorrectionLoading; + final ValueCorrection? result = + isCurrent && calculation is ValueCorrectionCalculated + ? calculation.result + : null; + final error = isCurrent && calculation is ValueCorrectionError + ? calculation.message + : null; + final offline = error == ServerErrorMessages.connectionError.message; + + final initialOut = _isBeforeSeries(_form.initialDate.text, index); + final endOut = _isBeforeSeries(_form.finalDate.text, index); + final outOfRange = initialOut || endOut; + final otherWarning = + !outOfRange && formState.warningBannerMessage.isNotEmpty + ? formState.warningBannerMessage + : null; + + final isMonthly = index.granularity == DateGranularity.month; + final dateFormatter = isMonthly + ? MonthYearInputFormatter() + : DateInputFormatter(); + final dateHint = isMonthly ? '01/2023' : '01/01/2023'; - void openIndexPicker() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Colors.transparent, - builder: (_) { - return CorrectionIndexPicker( - selectedIndex: formState.index, - onSelected: (index) { - formCubit.setIndex(index); - }, - ); - }, + FieldRow dateRow({ + required String label, + required ValueCorrectionField field, + required bool beforeSeries, + }) { + final fieldError = formState.fieldErrors[field]; + final hasError = beforeSeries || fieldError != null; + return FieldRow( + key: ValueKey(field), + label: label, + controller: _form.controllerFor(field), + hint: dateHint, + keyboardType: TextInputType.number, + inputFormatters: [dateFormatter], + state: hasError ? InputFieldState.error : InputFieldState.neutral, + errorText: beforeSeries + ? ValueCorrectionPage.outOfRangeText + : fieldError, ); } - return BlocListener( - listener: (context, state) { - if (state is ValueCorrectionCalculated) { - modular.Modular.to.pushNamed( - AppRoutes.correctionResult, - arguments: state.result, - ); - } - }, - child: Form( - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Scaffold( - appBar: const PageHeader(title: 'Correção de Valores'), - body: SafeArea( + return Scaffold( + body: Column( + children: [ + CalculatorBand( + title: ValueCorrectionPage.title, + statement: correctionStatement( + index: index, + result: result, + outOfRange: outOfRange, + offline: offline, + ), + onHelp: () => showHelpSheet(context, CalculatorKind.correction), + onClear: _clear, + ), + Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md + 4, - vertical: AppSpacing.md + 4, + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + AppSpacing.lg, ), child: Column( - spacing: AppSpacing.md, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const TitleWidget( - title: 'Correção de Valores', - subtitle: - 'Atualize um valor por índices de inflação, juros ou poupança.', - ), - const HelpCard( - message: - 'Escolha o índice e o período. Sem informar o valor, mostraremos só o fator de correção.', - ), - InputFormsResultCard( - label: 'Índice de correção', - hint: formState.index?.label ?? CorrectionIndex.ipca.label, - icon: Icons.trending_up, - controller: indexController, - state: InputFieldState.neutral, - readOnly: true, - onTap: openIndexPicker, - ), - Row( - spacing: AppSpacing.sm, - children: [ - Expanded( - child: InputFormsResultCard( - label: 'Data inicial', - hint: '01/2023', - icon: Icons.calendar_today, - controller: formCubit.initialDate, - inputFormatters: [dateInputFormatter], - state: - formState.fieldErrors[ValueCorrectionField - .initialDate] != - null - ? InputFieldState.error - : InputFieldState.neutral, - validator: (_) => formState - .fieldErrors[ValueCorrectionField.initialDate], + AbsorbPointer( + absorbing: isLoading, + child: FieldList( + children: [ + FieldRow.selection( + key: const ValueKey( + ValueCorrectionField.selectedIndex, + ), + label: 'Índice', + value: index.label, + onTap: () => _openIndexPicker(formState.index), ), - ), - Expanded( - child: InputFormsResultCard( - label: 'Data final', - hint: '12/2025', - icon: Icons.calendar_today, - controller: formCubit.finalDate, - inputFormatters: [dateInputFormatter], - state: - formState.fieldErrors[ValueCorrectionField - .finalDate] != - null - ? InputFieldState.error - : InputFieldState.neutral, - validator: (_) => formState - .fieldErrors[ValueCorrectionField.finalDate], + dateRow( + label: 'De', + field: ValueCorrectionField.initialDate, + beforeSeries: initialOut, ), - ), - ], - ), - if (formState.warningBannerMessage.isNotEmpty) - WarningBanner(message: formState.warningBannerMessage), - if (formState.showsPercentage) - InputFormsResultCard( - label: 'Percentual', - hint: '0,00', - icon: Icons.percent, - controller: formCubit.percentage, - inputFormatters: [DecimalInputFormatter()], - state: - formState.fieldErrors[ValueCorrectionField - .percentage] != - null - ? InputFieldState.error - : InputFieldState.neutral, - validator: (_) => formState - .fieldErrors[ValueCorrectionField.percentage], + dateRow( + label: 'Até', + field: ValueCorrectionField.finalDate, + beforeSeries: endOut, + ), + if (formState.showsPercentage) + FieldRow( + key: const ValueKey( + ValueCorrectionField.percentage, + ), + label: 'Percentual', + unit: '% do CDI', + hint: '100,00', + controller: _form.percentage, + inputFormatters: [DecimalInputFormatter()], + state: + formState.fieldErrors[ValueCorrectionField + .percentage] == + null + ? InputFieldState.neutral + : InputFieldState.error, + errorText: formState + .fieldErrors[ValueCorrectionField.percentage], + ), + FieldRow( + key: const ValueKey(ValueCorrectionField.value), + label: 'Valor', + prefix: 'R\$', + secondLine: 'opcional', + hint: '0,00', + controller: _form.value, + inputFormatters: [DecimalInputFormatter()], + ), + ], ), - InputFormsResultCard( - label: 'Valor a ser corrigido (R\$)', - hint: '5.000,00', - icon: Icons.attach_money, - controller: formCubit.value, - inputFormatters: [DecimalInputFormatter()], - state: - formState.fieldErrors[ValueCorrectionField.value] != - null - ? InputFieldState.error - : InputFieldState.neutral, - trailingBadge: 'opcional', - validator: (_) => - formState.fieldErrors[ValueCorrectionField.value], ), - if (calculationState is ValueCorrectionLoading) - const LinearProgressIndicator(), - if (calculationState is ValueCorrectionError) - ErrorBanner( - message: calculationState.message, - onRetry: retryCalculation, + _buildSupport(index), + if (outOfRange) + _NoticeWithAction( + notice: FormNotice( + title: 'Data fora do período', + message: index.availabilitySentence, + isError: true, + ), + actionLabel: 'Usar ${index.availableFromInputLabel}', + onAction: () { + if (initialOut) { + _form.initialDate.text = + index.availableFromInputLabel; + } + if (endOut) { + _form.finalDate.text = index.availableFromInputLabel; + } + }, + ) + else if (otherWarning != null) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: FormNotice( + title: 'Período fora do limite', + message: otherWarning, + ), + ) + else if (offline) + _NoticeWithAction( + notice: const FormNotice( + title: 'Sem conexão', + message: + 'Não foi possível buscar os dados no Banco ' + 'Central.', + isError: true, + ), + actionLabel: 'Tentar de novo', + onAction: _calculate, + ) + else if (error != null) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: FormNotice( + title: 'Não foi possível corrigir', + message: error, + isError: true, + ), ), - OptionsBottomForms( - onCalculate: formState.canCalculate ? calculate : null, - onClear: clear, - calculateLabel: 'Corrigir valor', - ), + if (result != null) + ResultDetails(summary: correctionResultSummary(result)), ], ), ), ), + BottomActionBar( + primaryLabel: 'Corrigir valor', + isLoading: isLoading, + onPrimary: formState.canCalculate ? _calculate : null, + onShare: result == null + ? null + : (origin) => widget.sharer.share( + buildShareText(result), + origin: origin, + ), + ), + ], + ), + ); + } + + Widget _buildSupport(CorrectionIndex index) { + final text = _supportText(index); + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Text( + text, + style: AppTextStyles.caption.copyWith( + color: AppColors.of(context).textSecondary, ), ), ); } } + +class _NoticeWithAction extends StatelessWidget { + final FormNotice notice; + final String actionLabel; + final VoidCallback onAction; + + const _NoticeWithAction({ + required this.notice, + required this.actionLabel, + required this.onAction, + }); + + @override + Widget build(BuildContext context) { + final palette = CalculatorScope.of(context); + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + notice, + Padding( + padding: const EdgeInsets.only(left: 30), + child: TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: palette.text, + padding: EdgeInsets.zero, + minimumSize: const Size(44, 44), + textStyle: AppTextStyles.rowLabel.copyWith( + fontWeight: FontWeight.w700, + ), + ), + child: Text(actionLabel), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/value_correction/presentation/pages/value_correction_result_page.dart b/lib/modules/value_correction/presentation/pages/value_correction_result_page.dart deleted file mode 100644 index 242cbac..0000000 --- a/lib/modules/value_correction/presentation/pages/value_correction_result_page.dart +++ /dev/null @@ -1,241 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; -import 'package:real_calc/core/themes/extensions/value_correction_result_theme.dart'; -import 'package:real_calc/core/themes/spacing.dart'; -import 'package:real_calc/core/widgets/options_bottom_forms.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; -import 'package:real_calc/modules/value_correction/domain/enum/correction_index.dart'; -import 'package:real_calc/modules/value_correction/presentation/utils/value_correction_share_text.dart'; - -import '../../domain/services/value_correction_sharer.dart'; - -class ValueCorrectionResultPage extends StatelessWidget { - final ValueCorrection result; - - const ValueCorrectionResultPage({ - super.key, - required this.result, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context).extension()!; - final currencyFormat = NumberFormat.currency( - locale: 'pt_BR', - symbol: 'R\$', - ); - final factorFormat = NumberFormat('#,##0.0000000', 'pt_BR'); - final decimalFormat = NumberFormat('#,##0.00', 'pt_BR'); - final index = _indexFor(result.index); - final consideredMonths = _monthsBetween( - result.period.initial, - result.period.end, - ); - - return Scaffold( - appBar: PageHeader(title: 'Correção de Valores'), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.lg, - ), - child: Column( - spacing: AppSpacing.md, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - '${index.label} — ${DateFormat('MM/yyyy', 'pt_BR').format(result.period.initial)} a ${DateFormat('MM/yyyy', 'pt_BR').format(result.period.end)}', - style: theme.subtitleStyle, - ), - Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: theme.spotlightBackground, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.spotlightBorderColor), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - Text(valueLabel, style: theme.spotlightLabelStyle), - if (hasAdjustedValue) _SpotlightBadge(theme: theme), - ], - ), - Text(displayedValue, style: theme.spotlightValueStyle), - Text( - supportingMessage(decimalFormat), - style: theme.spotlightVariationStyle.copyWith( - color: theme.variationPositiveColor, - ), - ), - ], - ), - ), - Container( - decoration: BoxDecoration( - color: theme.cardBackgroundColor, - border: Border.all(color: theme.cardBorderColor), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - ..._detailRows( - currencyFormat, - factorFormat, - index, - consideredMonths, - theme, - ), - ], - ), - ), - OptionsBottomForms( - onCalculate: () => Modular.get().share( - buildShareText(result), - ), - onClear: () => Modular.to.pop(), - calculateLabel: 'Compartilhar', - clearLabel: 'Editar Dados', - ), - ], - ), - ), - ), - ); - } - - bool get hasAdjustedValue => result.adjustedValue != null; - - String get valueLabel => - hasAdjustedValue ? 'Valor corrigido' : 'Fator de correção'; - - String get displayedValue { - final currencyFormat = NumberFormat.currency( - locale: 'pt_BR', - symbol: 'R\$', - ); - final factorFormat = NumberFormat('#,##0.0000000', 'pt_BR'); - - if (hasAdjustedValue) { - return currencyFormat.format(result.adjustedValue!); - } - - return factorFormat.format(result.factor); - } - - String supportingMessage(NumberFormat decimalFormat) { - final variationText = - '${result.variation >= 0 ? '+' : ''}${decimalFormat.format(result.variation)}%'; - - return hasAdjustedValue ? '$variationText no período' : 'fator acumulado'; - } - - List<_DetailRow> _detailRows( - NumberFormat currencyFormat, - NumberFormat factorFormat, - CorrectionIndex index, - int consideredMonths, - ValueCorrectionResultTheme theme, - ) { - final rows = <_DetailRow>[]; - - if (result.originalValue != null && hasAdjustedValue) { - rows.add( - _DetailRow( - label: 'Valor original', - value: currencyFormat.format(result.originalValue!), - theme: theme, - ), - ); - } - - rows.addAll([ - _DetailRow( - label: 'Fator de correção', - value: factorFormat.format(result.factor), - theme: theme, - ), - _DetailRow( - label: 'Meses considerados', - value: '$consideredMonths', - theme: theme, - ), - _DetailRow(label: 'Índice', value: index.label, theme: theme), - ]); - - return rows; - } - - CorrectionIndex _indexFor(int value) { - return CorrectionIndex.values.firstWhere( - (index) => index.sgsCode == value, - orElse: () => CorrectionIndex.ipca, - ); - } - - int _monthsBetween(DateTime start, DateTime end) { - return (end.year - start.year) * 12 + (end.month - start.month) + 1; - } -} - -class _SpotlightBadge extends StatelessWidget { - final ValueCorrectionResultTheme theme; - - const _SpotlightBadge({required this.theme}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.spotlightBackground, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'calculado', - style: theme.spotlightVariationStyle.copyWith(fontSize: 10), - ), - ); - } -} - -class _DetailRow extends StatelessWidget { - final String label; - final String value; - final ValueCorrectionResultTheme theme; - - const _DetailRow({ - required this.label, - required this.value, - required this.theme, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: theme.detailLabelStyle), - Flexible( - child: Text( - value, - textAlign: TextAlign.right, - style: theme.detailValueStyle, - ), - ), - ], - ), - ); - } -} diff --git a/lib/modules/value_correction/presentation/utils/correction_result_summary.dart b/lib/modules/value_correction/presentation/utils/correction_result_summary.dart new file mode 100644 index 0000000..adc8df6 --- /dev/null +++ b/lib/modules/value_correction/presentation/utils/correction_result_summary.dart @@ -0,0 +1,43 @@ +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; + +import '../../domain/entites/value_correction.dart'; +import 'correction_statement.dart'; + +ResultSummary correctionResultSummary(ValueCorrection correction) { + final originalValue = correction.originalValue; + final adjustedValue = correction.adjustedValue; + final factorLine = ResultDetailLine( + 'Fator de correção', + formatFactor(correction.factor), + ); + + if (originalValue == null || adjustedValue == null) { + return ResultSummary( + title: 'Da correção no período', + composition: null, + lines: [ + ResultDetailLine('Variação', formatVariation(correction.variation)), + factorLine, + ], + ); + } + + final original = toCents(originalValue); + final difference = toCents(adjustedValue) - original; + + return ResultSummary( + title: 'Do valor corrigido', + composition: ResultComposition.fromCents( + ownLabel: 'Valor original', + ownCents: original, + otherLabel: 'Correção', + otherCents: difference, + ), + lines: [ + ResultDetailLine('Valor original', formatCents(original)), + ResultDetailLine('Correção no período', formatCents(difference)), + factorLine, + ], + ); +} diff --git a/lib/modules/value_correction/presentation/utils/correction_statement.dart b/lib/modules/value_correction/presentation/utils/correction_statement.dart new file mode 100644 index 0000000..4a7790a --- /dev/null +++ b/lib/modules/value_correction/presentation/utils/correction_statement.dart @@ -0,0 +1,122 @@ +import 'package:intl/intl.dart'; +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; + +import '../../domain/entites/value_correction.dart'; +import '../../domain/enum/correction_index.dart'; +import '../../domain/enum/date_granularity.dart'; + +final _money = NumberFormat('#,##0.00', 'pt_BR'); +final _factor = NumberFormat('#,##0.0000000', 'pt_BR'); + +extension CorrectionIndexText on CorrectionIndex { + bool get _isFeminine => switch (this) { + CorrectionIndex.selic || + CorrectionIndex.tr || + CorrectionIndex.poupancaNova || + CorrectionIndex.poupancaVelha || + CorrectionIndex.taxaLegal => true, + _ => false, + }; + + String get article => _isFeminine ? 'A' : 'O'; + + String get by => _isFeminine ? 'pela' : 'pelo'; + + String get availabilitySentence => + '$article $label tem dados a partir de $availableFromInputLabel.'; + + String get sinceSentence => + '$article $label tem dados desde $availableFromInputLabel.'; + + String formatDate(DateTime date) { + String two(int value) => value.toString().padLeft(2, '0'); + final monthYear = '${two(date.month)}/${date.year}'; + return granularity == DateGranularity.month + ? monthYear + : '${two(date.day)}/$monthYear'; + } +} + +CorrectionIndex correctionIndexOf(ValueCorrection result) => + CorrectionIndex.values.firstWhere( + (index) => index.sgsCode == result.index, + orElse: () => CorrectionIndex.ipca, + ); + +String formatVariation(double percent) { + final sign = percent >= 0 ? '+' : ''; + return '$sign${formatPercent(percent)}'; +} + +String formatFactor(double factor) => _factor.format(factor); + +String correctionDuration( + CorrectionIndex index, + DateTime initial, + DateTime end, +) { + if (index.granularity == DateGranularity.month) { + final months = (end.year - initial.year) * 12 + end.month - initial.month; + final count = months + 1; + return count == 1 ? '1 mês' : '$count meses'; + } + final days = end.difference(initial).inDays; + return days == 1 ? '1 dia' : '$days dias'; +} + +BandStatement correctionStatement({ + required CorrectionIndex index, + ValueCorrection? result, + bool outOfRange = false, + bool offline = false, +}) { + if (offline) { + return const BandStatement( + kicker: 'Sem conexão com o', + value: 'Banco Central', + sub: 'Confira a internet e tente de novo.', + ); + } + + if (outOfRange) { + return BandStatement( + kicker: 'Para corrigir ${index.by} ${index.label},', + value: 'ajuste a data', + sub: index.availabilitySentence, + ); + } + + if (result == null) { + return const BandStatement( + kicker: 'Quanto vale hoje', + value: 'o seu dinheiro?', + sub: 'Escolha o índice, o período e o valor.', + ); + } + + final resultIndex = correctionIndexOf(result); + final period = + 'de ${resultIndex.formatDate(result.period.initial)} ' + 'a ${resultIndex.formatDate(result.period.end)}'; + final by = '${resultIndex.by} ${resultIndex.label}'; + final original = result.originalValue; + final adjusted = result.adjustedValue; + + if (original == null || adjusted == null) { + return BandStatement( + kicker: + '${by[0].toUpperCase()}${by.substring(1)}, $period, ' + 'a correção é de', + value: formatVariation(result.variation), + sub: 'Fator de correção ${formatFactor(result.factor)}', + ); + } + + return BandStatement( + kicker: 'Hoje, seus ${formatCents(toCents(original))} valem', + value: _money.format(toCents(adjusted) / 100), + isMoney: true, + sub: '${formatVariation(result.variation)} $by, $period', + ); +} diff --git a/lib/modules/value_correction/presentation/utils/value_correction_share_text.dart b/lib/modules/value_correction/presentation/utils/value_correction_share_text.dart index aa10e72..212ffee 100644 --- a/lib/modules/value_correction/presentation/utils/value_correction_share_text.dart +++ b/lib/modules/value_correction/presentation/utils/value_correction_share_text.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:real_calc/core/utils/share_text.dart'; import '../../domain/entites/value_correction.dart'; import '../../domain/enum/correction_index.dart'; @@ -47,6 +48,6 @@ String buildShareText(ValueCorrection correction) { ); } - lines.addAll(['', 'Simulação feita no RealCalc, sem valor oficial.']); + lines.addAll(['', shareDisclaimer]); return lines.join('\n'); } diff --git a/lib/modules/value_correction/presentation/widgets/correction_index_picker.dart b/lib/modules/value_correction/presentation/widgets/correction_index_picker.dart index 1c38461..c96f0a2 100644 --- a/lib/modules/value_correction/presentation/widgets/correction_index_picker.dart +++ b/lib/modules/value_correction/presentation/widgets/correction_index_picker.dart @@ -1,11 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/themes/radius.dart'; +import 'package:real_calc/core/themes/spacing.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; +import 'package:real_calc/core/widgets/app_sheet.dart'; -import '../../../../core/themes/color_tokens.dart'; -import '../../../../core/themes/extensions/index_picker_theme.dart'; import '../../domain/enum/correction_group.dart'; import '../../domain/enum/correction_index.dart'; +const correctionIndexPickerTitle = 'Escolha o índice'; +const correctionIndexSearchHint = 'Buscar índice'; +const correctionIndexAllLabel = 'Todos'; +const correctionIndexNoResults = 'Nenhum índice encontrado'; + extension CorrectionGroupLabel on CorrectionGroup { String get label => switch (this) { CorrectionGroup.inflation => 'Inflação', @@ -14,7 +24,36 @@ extension CorrectionGroupLabel on CorrectionGroup { }; } -class CorrectionIndexPicker extends StatelessWidget { +Future showCorrectionIndexPicker( + BuildContext context, { + CorrectionIndex? selectedIndex, + ValueChanged? onSelected, +}) { + return showAppSheet( + context: context, + title: correctionIndexPickerTitle, + kind: CalculatorKind.correction, + scrollable: false, + bodyBuilder: (_) => CorrectionIndexPicker( + selectedIndex: selectedIndex, + onSelected: onSelected, + ), + ); +} + +String foldForSearch(String text) { + const from = 'áàâãäéèêëíìîïóòôõöúùûüçñ'; + const to = 'aaaaaeeeeiiiiooooouuuucn'; + final lower = text.toLowerCase(); + final buffer = StringBuffer(); + for (final char in lower.split('')) { + final i = from.indexOf(char); + buffer.write(i == -1 ? char : to[i]); + } + return buffer.toString(); +} + +class CorrectionIndexPicker extends StatefulWidget { final List indices; final CorrectionIndex? selectedIndex; final ValueChanged? onSelected; @@ -26,159 +65,375 @@ class CorrectionIndexPicker extends StatelessWidget { this.onSelected, }); - Map> get _groupedIndices { - return { - CorrectionGroup.inflation: indices - .where((index) => index.group == CorrectionGroup.inflation) - .toList(), - CorrectionGroup.interest: indices - .where((index) => index.group == CorrectionGroup.interest) - .toList(), - CorrectionGroup.savings: indices - .where((index) => index.group == CorrectionGroup.savings) - .toList(), - }; + @override + State createState() => _CorrectionIndexPickerState(); +} + +class _CorrectionIndexPickerState extends State { + final _query = TextEditingController(); + final _searchFocus = FocusNode(); + CorrectionGroup? _group; + + @override + void initState() { + super.initState(); + _query.addListener(() => setState(() {})); + _searchFocus.addListener(() => setState(() {})); + } + + @override + void dispose() { + _query.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + bool _matches(CorrectionIndex index, String query) => + query.isEmpty || + foldForSearch(index.label).contains(query) || + foldForSearch(index.source).contains(query); + + void _select(CorrectionIndex index) { + widget.onSelected?.call(index); + Navigator.of(context).maybePop(); } @override Widget build(BuildContext context) { - final theme = Theme.of(context).extension() ?? - const IndexPickerTheme( - backgroundColor: ColorTokens.surface, - surfaceColor: ColorTokens.background, - dividerColor: ColorTokens.border, - selectedColor: ColorTokens.accentAmber, - selectedBorderColor: ColorTokens.accentAmber, - titleColor: ColorTokens.textPrimary, - subtitleColor: ColorTokens.textSecondary, - checkColor: ColorTokens.accentAmber, - sectionTitleStyle: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - color: ColorTokens.textSecondary, - ), - itemTitleStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: ColorTokens.textPrimary, - ), - itemSubtitleStyle: TextStyle( - fontSize: 12, - color: ColorTokens.textSecondary, - ), + final colors = AppColors.of(context); + final palette = + CalculatorScope.maybeOf(context) ?? + CalculatorPalette.of( + CalculatorKind.correction, + brightness: Theme.of(context).brightness, ); + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final query = foldForSearch(_query.text.trim()); + final expanded = _searchFocus.hasFocus; - final groupedIndices = _groupedIndices; - final sections = CorrectionGroup.values - .map((group) { - final items = groupedIndices[group] ?? const []; - if (items.isEmpty) return null; + final groups = [ + for (final group in CorrectionGroup.values) + if (_group == null || _group == group) + ( + group, + widget.indices + .where((i) => i.group == group && _matches(i, query)) + .toList(), + ), + ].where((entry) => entry.$2.isNotEmpty).toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final list = groups.isEmpty + ? Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.lg, + AppSpacing.screen, + AppSpacing.lg + bottomInset, + ), + child: Text( + correctionIndexNoResults, + style: AppTextStyles.body.copyWith(color: colors.textSecondary), + ), + ) + : ListView( + shrinkWrap: !expanded, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + AppSpacing.screen, + 0, + AppSpacing.screen, + AppSpacing.lg + bottomInset, + ), children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Text( - group.label.toUpperCase(), - style: theme.sectionTitleStyle, - ), - ), - ...items.map((index) { - final isSelected = selectedIndex == index; - final dateText = DateFormat('dd/MM/yyyy').format( - index.availableFrom, - ); - - return Container( - width: double.infinity, - margin: EdgeInsets.zero, - decoration: BoxDecoration( - color: isSelected ? theme.selectedColor : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? theme.selectedBorderColor - : Colors.transparent, - ), + for (final (group, items) in groups) ...[ + if (_group == null) _GroupHeader(group: group, colors: colors), + for (final index in items) + _IndexRow( + index: index, + selected: index == widget.selectedIndex, + colors: colors, + palette: palette, + onTap: () => _select(index), ), - child: Material( - color: Colors.transparent, - child: ListTile( - key: ValueKey(index.name), - dense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 2, - ), - title: Text( - index.label, - style: theme.itemTitleStyle.copyWith( - color: isSelected ? theme.titleColor : theme.titleColor, - ), - ), - subtitle: Text( - 'desde $dateText', - style: theme.itemSubtitleStyle, - ), - trailing: isSelected - ? Icon(Icons.check, color: theme.checkColor) - : null, - onTap: () { - onSelected?.call(index); - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } - }, - ), - ), - ); - }), + ], ], ); - }) - .whereType() - .toList(); + return Column( + mainAxisSize: expanded ? MainAxisSize.max : MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.sm, + AppSpacing.screen, + 0, + ), + child: _SearchField( + controller: _query, + focusNode: _searchFocus, + colors: colors, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screen, + AppSpacing.xs, + AppSpacing.screen, + 0, + ), + child: _GroupTabs( + active: _group, + colors: colors, + palette: palette, + onChanged: (group) => setState(() => _group = group), + ), + ), + Flexible(fit: expanded ? FlexFit.tight : FlexFit.loose, child: list), + ], + ); + } +} + +class _SearchField extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final AppColors colors; + + const _SearchField({ + required this.controller, + required this.focusNode, + required this.colors, + }); + + @override + Widget build(BuildContext context) { return Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.8, + height: 48, + decoration: BoxDecoration( + color: colors.elevated, + borderRadius: BorderRadius.circular(AppRadius.search), ), + child: TextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.search, + style: AppTextStyles.rowLabel.copyWith(color: colors.textPrimary), + decoration: InputDecoration( + hintText: correctionIndexSearchHint, + hintStyle: AppTextStyles.rowLabel.copyWith(color: colors.textHint), + prefixIcon: AppIcon( + AppIcons.search, + color: colors.textHint, + size: 22, + ), + suffixIcon: controller.text.isEmpty + ? null + : IconButton( + tooltip: 'Limpar busca', + onPressed: controller.clear, + color: colors.textSecondary, + icon: const AppIcon(AppIcons.close, size: 20), + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + contentPadding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ); + } +} + +class _GroupTabs extends StatelessWidget { + final CorrectionGroup? active; + final AppColors colors; + final CalculatorPalette palette; + final ValueChanged onChanged; + + const _GroupTabs({ + required this.active, + required this.colors, + required this.palette, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final tabs = <(CorrectionGroup?, String)>[ + (null, correctionIndexAllLabel), + for (final group in CorrectionGroup.values) (group, group.label), + ]; + + return DecoratedBox( decoration: BoxDecoration( - color: theme.backgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(18)), + border: Border(bottom: BorderSide(color: colors.hairline)), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 38, - height: 4, - margin: const EdgeInsets.only(top: 12, bottom: 8), - decoration: BoxDecoration( - color: theme.dividerColor, - borderRadius: BorderRadius.circular(99), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final (group, label) in tabs) + _Tab( + label: label, + selected: group == active, + colors: colors, + palette: palette, + onTap: () => onChanged(group), + ), + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + final String label; + final bool selected; + final AppColors colors; + final CalculatorPalette palette; + final VoidCallback onTap; + + const _Tab({ + required this.label, + required this.selected, + required this.colors, + required this.palette, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: InkWell( + key: ValueKey('tab-$label'), + onTap: onTap, + child: Container( + height: 44, + margin: const EdgeInsets.only(right: 20), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: selected ? palette.text : Colors.transparent, + width: 2, + ), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Selecione um índice', - style: theme.itemTitleStyle.copyWith(fontSize: 24), - ), + child: Text( + label, + style: AppTextStyles.sectionLabel.copyWith( + fontSize: 15, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + color: selected ? colors.textPrimary : colors.textSecondary, ), ), - Flexible( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 16), - children: sections, + ), + ), + ); + } +} + +class _GroupHeader extends StatelessWidget { + final CorrectionGroup group; + final AppColors colors; + + const _GroupHeader({required this.group, required this.colors}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 20, bottom: AppSpacing.xs), + child: Semantics( + header: true, + child: Text( + group.label.toUpperCase(), + style: AppTextStyles.sectionLabel.copyWith( + color: colors.textSecondary, + letterSpacing: 1.2, + ), + ), + ), + ); + } +} + +class _IndexRow extends StatelessWidget { + final CorrectionIndex index; + final bool selected; + final AppColors colors; + final CalculatorPalette palette; + final VoidCallback onTap; + + const _IndexRow({ + required this.index, + required this.selected, + required this.colors, + required this.palette, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + button: true, + selected: selected, + child: InkWell( + key: ValueKey(index.name), + onTap: onTap, + child: Container( + constraints: const BoxConstraints( + minHeight: AppSpacing.rowHeightWithSecondLine, + ), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.hairline)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + index.label, + style: AppTextStyles.rowLabel.copyWith( + color: colors.textPrimary, + fontWeight: selected + ? FontWeight.w800 + : FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '${index.source}, desde ${index.sinceMonthLabel}', + style: AppTextStyles.listDesc.copyWith( + color: colors.textSecondary, + ), + ), + ], + ), + ), + if (selected) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.sm), + child: AppIcon( + AppIcons.check, + color: palette.text, + size: 22, + ), + ), + ], ), ), - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 04bd3c0..9f8c549 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.3.0" + archive: + dependency: transitive + description: + name: archive + sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb + url: "https://pub.dev" + source: hosted + version: "4.6.0" args: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -81,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -230,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -285,14 +317,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d - url: "https://pub.dev" - source: hosted - version: "8.2.1" hooks: dependency: transitive description: @@ -325,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" intl: dependency: "direct main" description: @@ -365,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -581,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63" + url: "https://pub.dev" + source: hosted + version: "6.5.4" provider: dependency: transitive description: @@ -629,6 +677,62 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -907,5 +1011,5 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.11.0 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1e49580..2eed2a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.0+2 +version: 1.2.0+3 environment: sdk: ^3.10.4 @@ -35,7 +35,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.9 - google_fonts: ^8.2.1 flutter_bloc: ^9.1.1 flutter_modular: ^6.4.1 equatable: ^2.1.0 @@ -43,6 +42,7 @@ dependencies: dio: ^5.11.1 flutter_svg: ^2.3.0 share_plus: ^13.3.1 + shared_preferences: ^2.5.6 dev_dependencies: flutter_test: @@ -56,6 +56,7 @@ dev_dependencies: flutter_lints: ^6.0.0 mocktail: ^1.0.5 bloc_test: ^10.0.0 + flutter_launcher_icons: ^0.14.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -70,7 +71,25 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - assets/branding/app_icon.svg + - assets/branding/ + - assets/icons/ + - assets/fonts/ + + fonts: + - family: Manrope + fonts: + - asset: assets/fonts/Manrope-Medium.ttf + weight: 500 + - asset: assets/fonts/Manrope-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Manrope-Bold.ttf + weight: 700 + - asset: assets/fonts/Manrope-ExtraBold.ttf + weight: 800 + - family: Bricolage Grotesque + fonts: + - asset: assets/fonts/BricolageGrotesque-ExtraBold.ttf + weight: 800 # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images @@ -97,3 +116,14 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/launcher/icon_1024.png" + remove_alpha_ios: true + background_color_ios: "#14121F" + adaptive_icon_background: "#14121F" + adaptive_icon_foreground: "assets/launcher/adaptive_foreground_1024.png" + adaptive_icon_monochrome: "assets/launcher/adaptive_monochrome_1024.png" + adaptive_icon_foreground_inset: 0 diff --git a/screenshots/claro/01-inicio.png b/screenshots/claro/01-inicio.png new file mode 100644 index 0000000..d614208 Binary files /dev/null and b/screenshots/claro/01-inicio.png differ diff --git a/screenshots/claro/02-financiamento.png b/screenshots/claro/02-financiamento.png new file mode 100644 index 0000000..7fad2fd Binary files /dev/null and b/screenshots/claro/02-financiamento.png differ diff --git a/screenshots/claro/03-depositos.png b/screenshots/claro/03-depositos.png new file mode 100644 index 0000000..0ba225a Binary files /dev/null and b/screenshots/claro/03-depositos.png differ diff --git a/screenshots/claro/04-valor-futuro.png b/screenshots/claro/04-valor-futuro.png new file mode 100644 index 0000000..0af955a Binary files /dev/null and b/screenshots/claro/04-valor-futuro.png differ diff --git a/screenshots/claro/05-correcao.png b/screenshots/claro/05-correcao.png new file mode 100644 index 0000000..e19ee53 Binary files /dev/null and b/screenshots/claro/05-correcao.png differ diff --git a/screenshots/escuro/01-inicio.png b/screenshots/escuro/01-inicio.png new file mode 100644 index 0000000..e4e4177 Binary files /dev/null and b/screenshots/escuro/01-inicio.png differ diff --git a/screenshots/escuro/02-financiamento.png b/screenshots/escuro/02-financiamento.png new file mode 100644 index 0000000..9754bc8 Binary files /dev/null and b/screenshots/escuro/02-financiamento.png differ diff --git a/screenshots/escuro/03-depositos.png b/screenshots/escuro/03-depositos.png new file mode 100644 index 0000000..e2386cc Binary files /dev/null and b/screenshots/escuro/03-depositos.png differ diff --git a/screenshots/escuro/04-valor-futuro.png b/screenshots/escuro/04-valor-futuro.png new file mode 100644 index 0000000..be57782 Binary files /dev/null and b/screenshots/escuro/04-valor-futuro.png differ diff --git a/screenshots/escuro/05-correcao.png b/screenshots/escuro/05-correcao.png new file mode 100644 index 0000000..89556ff Binary files /dev/null and b/screenshots/escuro/05-correcao.png differ diff --git a/screenshots/screenshot-1.jpg b/screenshots/screenshot-1.jpg deleted file mode 100755 index 9c4a6f5..0000000 Binary files a/screenshots/screenshot-1.jpg and /dev/null differ diff --git a/screenshots/screenshot-2.jpg b/screenshots/screenshot-2.jpg deleted file mode 100755 index a1a1ba7..0000000 Binary files a/screenshots/screenshot-2.jpg and /dev/null differ diff --git a/screenshots/screenshot-3.jpg b/screenshots/screenshot-3.jpg deleted file mode 100755 index fd4f64f..0000000 Binary files a/screenshots/screenshot-3.jpg and /dev/null differ diff --git a/screenshots/screenshot-4.jpg b/screenshots/screenshot-4.jpg deleted file mode 100755 index d21fb89..0000000 Binary files a/screenshots/screenshot-4.jpg and /dev/null differ diff --git a/test/app_icon_finder.dart b/test/app_icon_finder.dart new file mode 100644 index 0000000..660abe6 --- /dev/null +++ b/test/app_icon_finder.dart @@ -0,0 +1,6 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +Finder findAppIcon(String asset) => find.byWidgetPredicate( + (widget) => widget is AppIcon && widget.asset == asset, +); diff --git a/test/core/themes/app_theme_test.dart b/test/core/themes/app_theme_test.dart new file mode 100644 index 0000000..9a9ff14 --- /dev/null +++ b/test/core/themes/app_theme_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; + +void main() { + Widget createSut(ThemeMode themeMode) { + return MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: const Scaffold(body: SizedBox()), + ); + } + + group('AppTheme', () { + testWidgets('disponibiliza os tokens corretos no tema claro', ( + tester, + ) async { + await tester.pumpWidget(createSut(ThemeMode.light)); + + final context = tester.element(find.byType(Scaffold)); + final theme = Theme.of(context); + final colors = theme.extension()!; + + expect(colors.background, AppColors.light.background); + expect(colors.sheet, AppColors.light.sheet); + expect(colors.elevated, AppColors.light.elevated); + expect(colors.hairline, AppColors.light.hairline); + expect(colors.textPrimary, AppColors.light.textPrimary); + expect(colors.textSecondary, AppColors.light.textSecondary); + expect(colors.textHint, AppColors.light.textHint); + expect(colors.error, AppColors.light.error); + expect(colors.scrim, AppColors.light.scrim); + expect(colors.bandHome, AppColors.light.bandHome); + expect(colors.onBandSoft, AppColors.light.onBandSoft); + expect(colors.brandAmber, AppColors.light.brandAmber); + expect(colors.onCalc, AppColors.light.onCalc); + expect(theme.colorScheme.primary, AppColors.light.textPrimary); + expect(tester.takeException(), isNull); + }); + + testWidgets('disponibiliza os tokens corretos no tema escuro', ( + tester, + ) async { + await tester.pumpWidget(createSut(ThemeMode.dark)); + + final context = tester.element(find.byType(Scaffold)); + final theme = Theme.of(context); + final colors = theme.extension()!; + + expect(colors.background, AppColors.dark.background); + expect(colors.sheet, AppColors.dark.sheet); + expect(colors.elevated, AppColors.dark.elevated); + expect(colors.hairline, AppColors.dark.hairline); + expect(colors.textPrimary, AppColors.dark.textPrimary); + expect(colors.textSecondary, AppColors.dark.textSecondary); + expect(colors.textHint, AppColors.dark.textHint); + expect(colors.error, AppColors.dark.error); + expect(colors.scrim, AppColors.dark.scrim); + expect(colors.bandHome, AppColors.dark.bandHome); + expect(colors.onBandSoft, AppColors.dark.onBandSoft); + expect(colors.brandAmber, AppColors.dark.brandAmber); + expect(colors.onCalc, AppColors.dark.onCalc); + expect(theme.colorScheme.primary, AppColors.dark.textPrimary); + expect(tester.takeException(), isNull); + }); + + test('AppColors suporta copyWith e interpolação', () { + final changed = AppColors.light.copyWith(background: Colors.blue); + expect(changed.background, Colors.blue); + expect(changed.textPrimary, AppColors.light.textPrimary); + + final middle = AppColors.light.lerp(AppColors.dark, 0.5); + expect( + middle.background, + Color.lerp(AppColors.light.background, AppColors.dark.background, 0.5), + ); + }); + }); +} diff --git a/test/core/themes/calculator_palette_test.dart b/test/core/themes/calculator_palette_test.dart new file mode 100644 index 0000000..75dc76b --- /dev/null +++ b/test/core/themes/calculator_palette_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; + +void main() { + group('CalculatorPalette', () { + test('defines the expected colors for light and dark themes', () { + const expected = { + CalculatorKind.financing: (0xFF364FC7, 0xFF364FC7, 0xFF91A7FF), + CalculatorKind.deposits: (0xFF087F5B, 0xFF0A6E50, 0xFF63E6BE), + CalculatorKind.futureValue: (0xFF6741D9, 0xFF6741D9, 0xFFB197FC), + CalculatorKind.correction: (0xFFB85300, 0xFFA04600, 0xFFFFA94D), + }; + + for (final entry in expected.entries) { + final light = CalculatorPalette.of( + entry.key, + brightness: Brightness.light, + ); + final dark = CalculatorPalette.of( + entry.key, + brightness: Brightness.dark, + ); + expect(light.solid.toARGB32(), entry.value.$1); + expect(light.text.toARGB32(), entry.value.$2); + expect(dark.solid, light.solid); + expect(dark.text.toARGB32(), entry.value.$3); + expect(light.onSolid, Colors.white); + expect(dark.onSolid, Colors.white); + } + }); + + test('compares palettes by value', () { + expect( + CalculatorPalette.of(CalculatorKind.deposits), + CalculatorPalette.of(CalculatorKind.deposits), + ); + expect( + CalculatorPalette.of(CalculatorKind.deposits).hashCode, + CalculatorPalette.of(CalculatorKind.deposits).hashCode, + ); + expect( + CalculatorPalette.of(CalculatorKind.deposits), + isNot( + CalculatorPalette.of( + CalculatorKind.deposits, + brightness: Brightness.dark, + ), + ), + ); + }); + + test('white text has at least 4.5:1 contrast on every solid color', () { + for (final kind in CalculatorKind.values) { + final palette = CalculatorPalette.of(kind); + expect( + (palette.onSolid.computeLuminance() + 0.05) / + (palette.solid.computeLuminance() + 0.05), + greaterThanOrEqualTo(4.5), + reason: '$kind must meet WCAG AA contrast', + ); + } + }); + }); + + testWidgets('correction scope applies its solid color to the text cursor', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light(), + home: const CalculatorScope( + kind: CalculatorKind.correction, + child: Scaffold(body: TextField()), + ), + ), + ); + + final editableText = tester.widget(find.byType(EditableText)); + expect(editableText.cursorColor, const Color(0xFFB85300)); + expect( + CalculatorScope.of(tester.element(find.byType(TextField))).text, + const Color(0xFFA04600), + ); + }); +} diff --git a/test/core/themes/theme_cubit_test.dart b/test/core/themes/theme_cubit_test.dart new file mode 100644 index 0000000..3a177fe --- /dev/null +++ b/test/core/themes/theme_cubit_test.dart @@ -0,0 +1,65 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/theme_cubit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + Future preferencesWith(Map values) { + SharedPreferences.setMockInitialValues(values); + return SharedPreferences.getInstance(); + } + + group('ThemeCubit', () { + test('sem escolha gravada, segue o tema do sistema', () async { + final cubit = ThemeCubit(await preferencesWith({})); + + expect(cubit.state, ThemeCubit.initialMode); + expect(cubit.state, ThemeMode.system); + }); + + test('lê a escolha gravada', () async { + final cubit = ThemeCubit( + await preferencesWith({ThemeCubit.storageKey: 'light'}), + ); + + expect(cubit.state, ThemeMode.light); + }); + + test('valor gravado desconhecido volta para o valor inicial', () async { + final cubit = ThemeCubit( + await preferencesWith({ThemeCubit.storageKey: 'sepia'}), + ); + + expect(cubit.state, ThemeCubit.initialMode); + }); + + test('grava a escolha e ela continua ao recriar o cubit', () async { + final preferences = await preferencesWith({}); + + await ThemeCubit(preferences).setMode(ThemeMode.system); + + expect(preferences.getString(ThemeCubit.storageKey), 'system'); + expect(ThemeCubit(preferences).state, ThemeMode.system); + }); + + late SharedPreferences preferences; + + blocTest( + 'toggle no escuro muda para o claro', + setUp: () async => preferences = await preferencesWith({}), + build: () => ThemeCubit(preferences), + act: (cubit) => cubit.toggle(Brightness.dark), + expect: () => [ThemeMode.light], + ); + + blocTest( + 'toggle no claro muda para o escuro', + setUp: () async => + preferences = await preferencesWith({ThemeCubit.storageKey: 'light'}), + build: () => ThemeCubit(preferences), + act: (cubit) => cubit.toggle(Brightness.light), + expect: () => [ThemeMode.dark], + ); + }); +} diff --git a/test/core/widgets/app_logo_test.dart b/test/core/widgets/app_logo_test.dart new file mode 100644 index 0000000..291d2ff --- /dev/null +++ b/test/core/widgets/app_logo_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_image.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/text_styles.dart'; +import 'package:real_calc/core/widgets/app_logo.dart'; + +void main() { + Future pump(WidgetTester tester, AppLogo logo) => tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold(body: Center(child: logo)), + ), + ); + + String assetOf(WidgetTester tester) => + (tester.widget(find.byType(SvgPicture)).bytesLoader + as SvgAssetLoader) + .assetName; + + group('AppLogo', () { + testWidgets('símbolo em brandAmber e nome com "Real" branco e "Calc" ' + 'âmbar', (tester) async { + await pump(tester, const AppLogo()); + + final symbol = tester.widget(find.byType(SvgPicture)); + expect( + symbol.colorFilter, + const ColorFilter.mode(Color(0xFFD4A657), BlendMode.srcIn), + ); + + final text = tester.widget( + find.descendant( + of: find.byType(AppLogo), + matching: find.byType(RichText), + ), + ); + final parts = []; + text.text.visitChildren((span) { + if (span is TextSpan && span.text != null) parts.add(span); + return true; + }); + expect(text.text.style!.fontFamily, AppTextStyles.wordmark.fontFamily); + expect(parts.map((part) => part.text), ['Real', 'Calc']); + expect(parts.first.style!.color, Colors.white); + expect(parts.last.style!.color, AppColors.dark.brandAmber); + }); + + testWidgets('usa o símbolo normal acima de 24px e o pequeno até 24px', ( + tester, + ) async { + await pump(tester, const AppLogo(symbolHeight: 32)); + expect(assetOf(tester), AppImage.appLogo); + + await pump(tester, const AppLogo(symbolHeight: 24)); + expect(assetOf(tester), AppImage.appLogoSmall); + }); + + testWidgets('sem o nome, mostra só o símbolo', (tester) async { + await pump(tester, const AppLogo(showWordmark: false)); + + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.byType(RichText), findsNothing); + }); + + testWidgets('é anunciado como "RealCalc"', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, const AppLogo()); + + expect(find.bySemanticsLabel('RealCalc'), findsOneWidget); + handle.dispose(); + }); + + test('os caminhos dos logos não têm barra dupla', () { + expect(AppImage.appLogo, 'assets/branding/app_icon.svg'); + expect(AppImage.appLogoSmall, 'assets/branding/app_icon_pequeno.svg'); + }); + }); +} diff --git a/test/core/widgets/bottom_action_bar_test.dart b/test/core/widgets/bottom_action_bar_test.dart new file mode 100644 index 0000000..6a30eb7 --- /dev/null +++ b/test/core/widgets/bottom_action_bar_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; + +void main() { + const screen = Size(400, 800); + + Future pump( + WidgetTester tester, + Widget bar, { + EdgeInsets viewPadding = EdgeInsets.zero, + EdgeInsets viewInsets = EdgeInsets.zero, + Widget? content, + }) async { + tester.view.physicalSize = screen; + tester.view.devicePixelRatio = 1; + tester.view.padding = FakeViewPadding(bottom: viewPadding.bottom); + tester.view.viewInsets = FakeViewPadding(bottom: viewInsets.bottom); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: CalculatorScope( + kind: CalculatorKind.deposits, + child: Scaffold( + body: Column( + children: [ + Expanded(child: content ?? const SizedBox.expand()), + bar, + ], + ), + ), + ), + ), + ); + } + + group('BottomActionBar', () { + testWidgets('sem resultado, compartilhar fica apagado e desabilitado', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + BottomActionBar(primaryLabel: 'Calcular', onPrimary: () {}), + ); + + final opacity = tester.widget( + find.ancestor( + of: find.byType(OutlinedButton), + matching: find.byType(Opacity), + ), + ); + expect(opacity.opacity, 0.4); + expect( + tester.widget(find.byType(OutlinedButton)).onPressed, + isNull, + ); + expect( + tester.getSemantics(find.bySemanticsLabel(BottomActionBar.shareLabel)), + isSemantics(isButton: true, hasEnabledState: true, isEnabled: false), + ); + handle.dispose(); + }); + + testWidgets('com resultado, compartilhar fica ativo', (tester) async { + var shared = 0; + Rect? sharedFrom; + await pump( + tester, + BottomActionBar( + primaryLabel: 'Calcular', + onPrimary: () {}, + onShare: (origin) { + shared++; + sharedFrom = origin; + }, + ), + ); + + final opacity = tester.widget( + find.ancestor( + of: find.byType(OutlinedButton), + matching: find.byType(Opacity), + ), + ); + expect(opacity.opacity, 1); + await tester.tap(find.bySemanticsLabel(BottomActionBar.shareLabel)); + expect(shared, 1); + expect(sharedFrom, tester.getRect(find.byType(OutlinedButton))); + }); + + testWidgets('botões de 56px, principal em pílula na cor da calculadora ' + 'e compartilhar com contorno hairline', (tester) async { + var calculated = 0; + await pump( + tester, + BottomActionBar( + primaryLabel: 'Calcular', + onPrimary: () => calculated++, + ), + ); + + expect(tester.getSize(find.byType(FilledButton)).height, 56); + expect(tester.getSize(find.byType(OutlinedButton)), const Size(56, 56)); + + final primary = tester.widget(find.byType(FilledButton)); + final palette = CalculatorPalette.of(CalculatorKind.deposits); + expect(primary.style!.backgroundColor!.resolve({}), palette.solid); + expect(primary.style!.shape!.resolve({}), isA()); + + final share = tester.widget(find.byType(OutlinedButton)); + expect(share.style!.side!.resolve({})!.color, AppColors.light.hairline); + + await tester.tap(find.text('Calcular')); + expect(calculated, 1); + }); + + testWidgets('carregando: indicador no botão e sem toque', (tester) async { + var calculated = 0; + await pump( + tester, + BottomActionBar( + primaryLabel: 'Corrigir valor', + onPrimary: () => calculated++, + isLoading: true, + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Corrigir valor'), findsNothing); + await tester.tap(find.byType(FilledButton)); + expect(calculated, 0); + }); + + testWidgets('fundo background e padding 12/24/30', (tester) async { + await pump( + tester, + BottomActionBar(primaryLabel: 'Calcular', onPrimary: () {}), + ); + + final bar = tester.getRect(find.byType(BottomActionBar)); + final share = tester.getRect(find.byType(OutlinedButton)); + final primary = tester.getRect(find.byType(FilledButton)); + + expect(share.left - bar.left, 24); + expect(bar.right - primary.right, 24); + expect(share.top - bar.top, 12); + expect(bar.bottom - share.bottom, 30); + expect( + tester + .widget( + find + .descendant( + of: find.byType(BottomActionBar), + matching: find.byType(ColoredBox), + ) + .first, + ) + .color, + AppColors.light.background, + ); + }); + + testWidgets('respeita a área segura de baixo', (tester) async { + await pump( + tester, + BottomActionBar(primaryLabel: 'Calcular', onPrimary: () {}), + viewPadding: const EdgeInsets.only(bottom: 48), + ); + + final bar = tester.getRect(find.byType(BottomActionBar)); + final share = tester.getRect(find.byType(OutlinedButton)); + expect(bar.bottom, screen.height); + expect(bar.bottom - share.bottom, 48); + }); + + testWidgets('sobe com o teclado sem cobrir o campo em foco', ( + tester, + ) async { + await pump( + tester, + BottomActionBar(primaryLabel: 'Calcular', onPrimary: () {}), + viewInsets: const EdgeInsets.only(bottom: 300), + content: ListView( + children: [ + const SizedBox(height: 600), + TextField( + key: const Key('campo'), + autofocus: true, + scrollPadding: const EdgeInsets.only(bottom: 120), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + final bar = tester.getRect(find.byType(BottomActionBar)); + final field = tester.getRect(find.byKey(const Key('campo'))); + expect(bar.bottom, screen.height - 300); + expect(field.bottom, lessThanOrEqualTo(bar.top)); + }); + }); + + group('showFieldsClearedSnackBar', () { + testWidgets('mostra "Campos limpos" com "Desfazer" por 4s', (tester) async { + var undone = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => + showFieldsClearedSnackBar(context, onUndo: () => undone++), + child: const Text('limpar'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('limpar')); + await tester.pumpAndSettle(); + expect(find.text('Campos limpos'), findsOneWidget); + + await tester.tap(find.text('Desfazer')); + await tester.pumpAndSettle(); + expect(undone, 1); + + await tester.tap(find.text('limpar')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 3)); + expect(find.text('Campos limpos'), findsOneWidget); + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(find.text('Campos limpos'), findsNothing); + }); + }); +} diff --git a/test/core/widgets/calculator_band/calculator_band_test.dart b/test/core/widgets/calculator_band/calculator_band_test.dart new file mode 100644 index 0000000..ab6e751 --- /dev/null +++ b/test/core/widgets/calculator_band/calculator_band_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../../app_icon_finder.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; + +const initial = BandStatement( + kicker: 'Descubra quanto fica', + value: 'a prestação', + sub: 'de um financiamento', +); +const question = BandStatement( + kicker: 'Vamos descobrir', + value: 'o prazo?', + sub: 'Preencha os outros três campos', +); +const answer = BandStatement( + kicker: 'Sua prestação fica em', + value: '236,40', + isMoney: true, + sub: 'por 12 meses', +); +const warning = BandStatement( + kicker: 'Para calcular, falta', + value: 'a taxa', + sub: 'Preencha a taxa de juros ao mês', +); + +void main() { + Future pump( + WidgetTester tester, { + BandStatement statement = initial, + CalculatorKind kind = CalculatorKind.financing, + VoidCallback? onBack, + VoidCallback? onHelp, + VoidCallback? onClear, + double width = 400, + double textScale = 1, + }) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 800), + padding: const EdgeInsets.only(top: 24), + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: CalculatorScope( + kind: kind, + child: Align( + alignment: Alignment.topCenter, + child: CalculatorBand( + title: 'Financiamento', + statement: statement, + onBack: onBack, + onHelp: onHelp, + onClear: onClear, + ), + ), + ), + ), + ), + ), + ); + } + + double valueFontSize(WidgetTester tester, String value) => + tester.widget(find.text(value)).style!.fontSize!; + + group('CalculatorBand', () { + testWidgets('a frase muda entre inicial, pergunta, resposta e aviso', ( + tester, + ) async { + for (final statement in [initial, question, answer, warning]) { + await pump(tester, statement: statement); + + expect(find.text(statement.kicker!), findsOneWidget); + expect(find.text(statement.value), findsOneWidget); + expect(find.text(statement.sub!), findsOneWidget); + expect( + find.text('R\$'), + statement.isMoney ? findsOneWidget : findsNothing, + ); + } + }); + + testWidgets('a frase é uma liveRegion com o texto completo', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pump(tester, statement: answer); + + expect( + find.bySemanticsLabel('Sua prestação fica em R\$ 236,40 por 12 meses'), + findsOneWidget, + ); + final node = tester.getSemantics( + find.bySemanticsLabel(RegExp('Sua prestação')), + ); + expect(node, isSemantics(isLiveRegion: true)); + handle.dispose(); + }); + + testWidgets('fundo na cor da calculadora, ícones em onSolid e barra de ' + 'status com ícones claros nas quatro cores', (tester) async { + for (final kind in CalculatorKind.values) { + await pump(tester, kind: kind, onHelp: () {}, onClear: () {}); + final palette = CalculatorPalette.of(kind); + + final background = tester.widget( + find + .descendant( + of: find.byType(CalculatorBand), + matching: find.byType(ColoredBox), + ) + .first, + ); + expect(background.color, palette.solid); + + final region = tester.widget>( + find.descendant( + of: find.byType(CalculatorBand), + matching: find.byType(AnnotatedRegion), + ), + ); + expect(region.value.statusBarIconBrightness, Brightness.light); + expect(region.value.statusBarBrightness, Brightness.dark); + + for (final button in tester.widgetList( + find.byType(IconButton), + )) { + expect(button.color, palette.onSolid); + } + } + }); + + testWidgets('fica atrás da barra de status', (tester) async { + await pump(tester); + + expect(tester.getTopLeft(find.byType(CalculatorBand)).dy, 0); + expect( + tester.getTopLeft(find.text('Financiamento')).dy, + greaterThanOrEqualTo(24), + ); + }); + + testWidgets('botões com rótulos, área de 44px e ações', (tester) async { + final handle = tester.ensureSemantics(); + final taps = []; + await pump( + tester, + onBack: () => taps.add('voltar'), + onHelp: () => taps.add('ajuda'), + onClear: () => taps.add('limpar'), + ); + + for (final label in [ + CalculatorBand.backLabel, + CalculatorBand.helpLabel, + CalculatorBand.clearLabel, + ]) { + final button = find.bySemanticsLabel(label); + expect(button, findsOneWidget); + expect( + tester.getSemantics(button), + isSemantics(isButton: true, hasTapAction: true), + ); + expect(tester.getSize(button), const Size.square(44)); + await tester.tap(button); + } + expect(taps, ['voltar', 'ajuda', 'limpar']); + handle.dispose(); + }); + + testWidgets('sem onHelp e onClear, só mostra voltar', (tester) async { + await pump(tester); + + expect(find.byType(IconButton), findsOneWidget); + expect(findAppIcon(AppIcons.back), findsOneWidget); + }); + + testWidgets('valor curto fica em 48px', (tester) async { + await pump(tester, statement: answer); + + expect(valueFontSize(tester, '236,40'), 48); + }); + + testWidgets('em 360dp e fonte em 130%, as frases quebram e o número ' + 'encolhe sem cortar', (tester) async { + const long = BandStatement( + kicker: 'O total que você vai pagar ao fim do financiamento é de', + value: '1.234.567,89', + isMoney: true, + sub: 'somando todas as 360 prestações mensais com os juros', + ); + await pump(tester, statement: long, width: 360, textScale: 1.3); + + expect(tester.takeException(), isNull); + + final kickerHeight = tester.getSize(find.text(long.kicker!)).height; + final subHeight = tester.getSize(find.text(long.sub!)).height; + final oneLine = 15 * 1.3; + expect(kickerHeight, greaterThan(oneLine * 1.5)); + expect(subHeight, greaterThan(oneLine * 1.5)); + + final fontSize = valueFontSize(tester, long.value); + expect(fontSize, lessThan(48)); + expect(fontSize, greaterThanOrEqualTo(CalculatorBand.minValueSize)); + + final band = tester.getRect(find.byType(CalculatorBand)); + final value = tester.getRect(find.text(long.value)); + expect(value.left, greaterThanOrEqualTo(band.left)); + expect(value.right, lessThanOrEqualTo(band.right)); + }); + }); +} diff --git a/test/core/widgets/error_banner_test.dart b/test/core/widgets/error_banner_test.dart deleted file mode 100644 index 78475b2..0000000 --- a/test/core/widgets/error_banner_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/error_banner_theme.dart'; -import 'package:real_calc/core/widgets/error_banner.dart'; - -void main() { - const errorTheme = ErrorBannerTheme( - backgroundColor: Color(0x33224466), - borderColor: Color(0xFF224466), - iconColor: Color(0xFFFFAA00), - retryTextColor: Colors.white, - messageStyle: TextStyle(fontSize: 14, color: Colors.red), - padding: EdgeInsets.all(16), - borderRadius: 10, - iconSize: 20, - iconMessageSpacing: 8, - retrySpacing: 6, - ); - - Widget buildSut({VoidCallback? onRetry}) { - return MaterialApp( - theme: ThemeData(extensions: [errorTheme]), - home: Scaffold( - body: ErrorBanner(message: 'Falha ao carregar dados', onRetry: onRetry), - ), - ); - } - - group('ErrorBanner', () { - testWidgets('exibe a mensagem, o ícone e a ação de retry', (tester) async { - await tester.pumpWidget(buildSut(onRetry: () {})); - - expect(find.text('Falha ao carregar dados'), findsOneWidget); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - expect(find.text('Tentar novamente'), findsOneWidget); - }); - - testWidgets('esconde a ação de retry quando não há callback', ( - tester, - ) async { - await tester.pumpWidget(buildSut()); - - expect(find.text('Falha ao carregar dados'), findsOneWidget); - expect(find.text('Tentar novamente'), findsNothing); - }); - - testWidgets('aciona o callback ao tocar em tentar novamente', ( - tester, - ) async { - var retried = false; - await tester.pumpWidget(buildSut(onRetry: () => retried = true)); - - await tester.tap(find.text('Tentar novamente')); - - expect(retried, isTrue); - }); - - testWidgets('aplica as cores, dimensões e estilos definidos no tema', ( - tester, - ) async { - await tester.pumpWidget(buildSut(onRetry: () {})); - - final container = tester.widget( - find - .ancestor( - of: find.text('Falha ao carregar dados'), - matching: find.byType(Container), - ) - .first, - ); - final decoration = container.decoration! as BoxDecoration; - final icon = tester.widget( - find.byIcon(Icons.warning_amber_rounded), - ); - final message = tester.widget(find.text('Falha ao carregar dados')); - final retry = tester.widget( - find.widgetWithText(TextButton, 'Tentar novamente'), - ); - - expect(container.padding, const EdgeInsets.all(16)); - expect(decoration.color, errorTheme.backgroundColor); - expect(decoration.borderRadius, BorderRadius.circular(10)); - expect((decoration.border! as Border).top.color, errorTheme.borderColor); - expect(icon.color, errorTheme.iconColor); - expect(icon.size, 20); - expect(message.style, errorTheme.messageStyle); - expect(retry.style?.foregroundColor?.resolve({}), Colors.white); - }); - - test('ErrorBannerTheme suporta copyWith e interpolação', () { - final copied = errorTheme.copyWith(borderRadius: 14); - final interpolated = errorTheme.lerp(copied, 0.5); - - expect(copied.borderRadius, 14); - expect(interpolated.borderRadius, 12); - }); - }); -} diff --git a/test/core/widgets/field_list/field_row_test.dart b/test/core/widgets/field_list/field_row_test.dart new file mode 100644 index 0000000..dc47389 --- /dev/null +++ b/test/core/widgets/field_list/field_row_test.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../../app_icon_finder.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/widgets/field_list/field_list.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/types/input_field_state.dart'; + +void main() { + const colors = AppColors.light; + final palette = CalculatorPalette.of(CalculatorKind.correction); + + late TextEditingController controller; + + setUp(() => controller = TextEditingController()); + tearDown(() => controller.dispose()); + + Future pump( + WidgetTester tester, + Widget row, { + double width = 400, + double textScale = 1, + }) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 800), + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CalculatorScope( + kind: CalculatorKind.correction, + child: FieldList(children: [row]), + ), + ), + ), + ), + ), + ); + } + + BorderSide bottomLine(WidgetTester tester) { + final container = tester.widget( + find + .descendant( + of: find.byType(FieldRow), + matching: find.byType(Container), + ) + .first, + ); + return ((container.decoration! as BoxDecoration).border! as Border).bottom; + } + + TextStyle valueStyle(WidgetTester tester) => + tester.widget(find.byType(EditableText)).style; + + group('FieldRow', () { + testWidgets('neutro: rótulo, segunda linha e linha hairline', ( + tester, + ) async { + controller.text = '1.200,00'; + await pump( + tester, + FieldRow( + label: 'Depósito', + secondLine: 'todo mês', + prefix: 'R\$', + controller: controller, + ), + ); + + expect(find.text('Depósito'), findsOneWidget); + expect(find.text('todo mês'), findsOneWidget); + expect(find.text('R\$'), findsOneWidget); + expect(bottomLine(tester).color, colors.hairline); + expect(bottomLine(tester).width, 1); + expect(valueStyle(tester).color, colors.textPrimary); + expect(findAppIcon(AppIcons.check), findsNothing); + expect( + tester.getSize(find.byType(FieldRow)).height, + greaterThanOrEqualTo(64), + ); + }); + + testWidgets('sem segunda linha, tem altura mínima de 58', (tester) async { + await pump(tester, FieldRow(label: 'Taxa', controller: controller)); + + expect( + tester.getSize(find.byType(FieldRow)).height, + greaterThanOrEqualTo(58), + ); + // Vazio, o campo ocupa a largura do hint "0". + expect(find.text('0'), findsOneWidget); + expect( + tester.getSize(find.byType(TextField)).width, + greaterThanOrEqualTo(tester.getSize(find.text('0')).width), + ); + }); + + testWidgets('em edição: tocar na linha foca o campo, linha de 2px e ' + 'cursor na cor da calculadora', (tester) async { + await pump( + tester, + FieldRow(label: 'Valor', prefix: 'R\$', controller: controller), + ); + + // Toca no rótulo, longe do campo: a linha inteira é área de toque. + await tester.tap(find.text('Valor')); + await tester.pump(); + + final editable = tester.widget(find.byType(EditableText)); + expect(editable.focusNode.hasFocus, isTrue); + expect(editable.cursorColor, palette.solid); + expect(bottomLine(tester).color, palette.solid); + expect(bottomLine(tester).width, 2); + }); + + testWidgets('a descobrir: texto, pílula, linha e toque para calcular', ( + tester, + ) async { + var calculated = 0; + await pump( + tester, + FieldRow( + label: 'Prestação', + controller: controller, + state: InputFieldState.unknown, + onCalculate: () => calculated++, + ), + ); + + expect(find.text(FieldRow.unknownText), findsOneWidget); + expect(find.text(FieldRow.calculateText), findsOneWidget); + expect(find.byType(TextField), findsNothing); + expect(bottomLine(tester).color, palette.solid); + expect(bottomLine(tester).width, 2); + + final pill = tester.widget(find.text(FieldRow.calculateText)); + expect(pill.style!.color, palette.onSolid); + final pillBox = tester.widget( + find + .ancestor( + of: find.text(FieldRow.calculateText), + matching: find.byType(Container), + ) + .first, + ); + expect((pillBox.decoration! as BoxDecoration).color, palette.solid); + + await tester.tap(find.text('Prestação')); + expect(calculated, 1); + }); + + testWidgets('calculado: texto, ✓ e valor em destaque com peso 800', ( + tester, + ) async { + controller.text = '1.523,42'; + await pump( + tester, + FieldRow( + label: 'Prestação', + prefix: 'R\$', + controller: controller, + state: InputFieldState.calculated, + ), + ); + + expect(find.text(FieldRow.calculatedText), findsOneWidget); + expect( + tester.widget(find.text(FieldRow.calculatedText)).style!.color, + palette.text, + ); + expect( + tester.widget(findAppIcon(AppIcons.check)).color, + palette.text, + ); + expect(valueStyle(tester).color, palette.text); + expect(valueStyle(tester).fontWeight, FontWeight.w800); + expect(bottomLine(tester).color, colors.hairline); + }); + + testWidgets('erro: linha de 1,5px, valor e segunda linha em error', ( + tester, + ) async { + controller.text = '01/1900'; + await pump( + tester, + FieldRow( + label: 'Data inicial', + controller: controller, + state: InputFieldState.error, + errorText: 'fora do período', + ), + ); + + expect(find.text('fora do período'), findsOneWidget); + expect( + tester.widget(find.text('fora do período')).style!.color, + colors.error, + ); + expect(valueStyle(tester).color, colors.error); + expect(bottomLine(tester).color, colors.error); + expect(bottomLine(tester).width, 1.5); + }); + + testWidgets('seleção: valor, chevron e toque abre o seletor', ( + tester, + ) async { + var opened = 0; + await pump( + tester, + FieldRow.selection( + label: 'Índice', + value: 'IPCA', + onTap: () => opened++, + ), + ); + + expect(find.text('IPCA'), findsOneWidget); + expect(findAppIcon(AppIcons.chevronRight), findsOneWidget); + expect(find.byType(TextField), findsNothing); + + await tester.tap(find.text('Índice')); + expect(opened, 1); + }); + + testWidgets('prefixo, valor e unidade ficam colados e na borda', ( + tester, + ) async { + controller.text = '12'; + await pump( + tester, + FieldRow(label: 'Prazo', unit: 'meses', controller: controller), + ); + + final field = tester.getRect(find.byType(TextField)); + final unit = tester.getRect(find.text('meses')); + final row = tester.getRect(find.byType(FieldRow)); + + expect(unit.left - field.right, 4); + expect(unit.right, row.right); + }); + + testWidgets('usa o teclado informado e mantém espaço acima do teclado', ( + tester, + ) async { + await pump( + tester, + FieldRow( + label: 'Prazo', + controller: controller, + keyboardType: TextInputType.number, + ), + ); + + final field = tester.widget(find.byType(TextField)); + expect(field.keyboardType, TextInputType.number); + expect(field.scrollPadding.bottom, greaterThanOrEqualTo(100)); + + await pump(tester, FieldRow(label: 'Taxa', controller: controller)); + expect( + tester.widget(find.byType(TextField)).keyboardType, + const TextInputType.numberWithOptions(decimal: true), + ); + }); + + testWidgets('em 360dp e fonte em 130%, a segunda linha quebra e o valor ' + 'não é cortado', (tester) async { + controller.text = '1.234.567,89'; + await pump( + tester, + FieldRow( + label: 'Valor corrigido', + prefix: 'R\$', + controller: controller, + state: InputFieldState.error, + errorText: 'fora do período disponível para o índice escolhido', + ), + width: 360, + textScale: 1.3, + ); + + expect(tester.takeException(), isNull); + + final secondLine = tester.getSize( + find.text('fora do período disponível para o índice escolhido'), + ); + final singleLine = tester.getSize(find.text('Valor corrigido')); + expect(secondLine.height, greaterThan(singleLine.height)); + + final painter = TextPainter( + text: TextSpan(text: controller.text, style: valueStyle(tester)), + textDirection: TextDirection.ltr, + textScaler: const TextScaler.linear(1.3), + )..layout(); + // O campo tem a largura do texto inteiro e fica dentro da linha. + expect( + tester.getSize(find.byType(TextField)).width, + greaterThanOrEqualTo(painter.width), + ); + painter.dispose(); + final row = tester.getRect(find.byType(FieldRow)); + final field = tester.getRect(find.byType(TextField)); + expect(field.left, greaterThanOrEqualTo(row.left)); + expect(field.right, lessThanOrEqualTo(row.right)); + }); + + group('Semantics', () { + testWidgets('a descobrir é anunciado como botão', (tester) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + FieldRow( + label: 'Prestação', + controller: controller, + state: InputFieldState.unknown, + onCalculate: () {}, + ), + ); + + expect( + tester.getSemantics(find.byType(FieldRow)), + isSemantics( + label: 'Prestação, a descobrir', + isButton: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + + testWidgets('campo anuncia rótulo, unidade, estado e valor', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + controller.text = '12'; + await pump( + tester, + FieldRow( + label: 'Prazo', + unit: 'meses', + controller: controller, + state: InputFieldState.calculated, + ), + ); + + expect( + tester.getSemantics(find.byType(FieldRow)), + isSemantics( + label: 'Prazo, meses, calculado para você', + value: '12', + isTextField: true, + ), + ); + handle.dispose(); + }); + + testWidgets('seleção anuncia rótulo e valor como botão', (tester) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + FieldRow.selection(label: 'Índice', value: 'IPCA', onTap: () {}), + ); + + expect( + tester.getSemantics(find.byType(FieldRow)), + isSemantics( + label: 'Índice, IPCA', + isButton: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + }); + }); + + group('FieldList', () { + testWidgets('desenha a linha hairline no topo', (tester) async { + await pump(tester, FieldRow(label: 'Taxa', controller: controller)); + + final box = tester.widget( + find + .descendant( + of: find.byType(FieldList), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final border = (box.decoration as BoxDecoration).border! as Border; + expect(border.top.color, colors.hairline); + expect(find.byType(Card), findsNothing); + }); + }); +} diff --git a/test/core/widgets/help_card_test.dart b/test/core/widgets/help_card_test.dart deleted file mode 100644 index 40bb6f8..0000000 --- a/test/core/widgets/help_card_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/help_card_theme.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/themes/text_styles.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; - -void main() { - group('HelpCard', () { - const testMessage = 'Esta é uma mensagem de dica ou ajuda importante.'; - - Widget createSut() { - return MaterialApp( - theme: ThemeData( - extensions: [ - HelpCardTheme( - messageStyle: AppTextStyles.helpCardMessage, - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber, - iconBackgroundColor: ColorTokens.surface, - iconColor: Colors.white, - ), - ], - ), - home: const Scaffold( - body: HelpCard(message: testMessage), - ), - ); - } - - test('HelpCardTheme deve ter os estilos corretos', () { - final theme = HelpCardTheme( - messageStyle: AppTextStyles.helpCardMessage, - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber, - iconBackgroundColor: ColorTokens.accentAmber, - iconColor: Colors.white, - ); - - expect(theme.messageStyle.color, ColorTokens.textPrimary); - expect(theme.messageStyle.fontSize, 14); - expect(theme.messageStyle.fontWeight, FontWeight.w400); - }); - - testWidgets('Deve exibir o texto da mensagem e o ícone de lâmpada', (tester) async { - await tester.pumpWidget(createSut()); - - expect(find.text(testMessage), findsOneWidget); - - final textFinder = find.text(testMessage); - final RenderParagraph renderObject = tester.renderObject(textFinder); - final resolvedStyle = renderObject.text.style; - - expect(resolvedStyle?.color, ColorTokens.textPrimary); - expect(resolvedStyle?.fontSize, 14); - expect(resolvedStyle?.fontWeight, FontWeight.w400); - - expect(find.byIcon(Icons.lightbulb), findsOneWidget); - }); - - testWidgets('Deve validar a decoração e estilos do container principal', (tester) async { - await tester.pumpWidget(createSut()); - - final containerFinder = find.byType(Container); - final Container mainContainer = tester.widget(containerFinder.first); - final mainDecoration = mainContainer.decoration as BoxDecoration?; - - expect(mainDecoration, isNotNull); - expect(mainDecoration!.color, ColorTokens.surface); - expect(mainDecoration.borderRadius, BorderRadius.circular(18)); - - final border = mainDecoration.border as Border?; - expect(border, isNotNull); - expect(border!.top.color, ColorTokens.accentAmber.withValues(alpha: 0.4)); - }); - - testWidgets('Deve validar a decoração do container menor que envolve o ícone', (tester) async { - await tester.pumpWidget(createSut()); - - final containerFinder = find.byType(Container); - final Container iconContainer = tester.widget(containerFinder.at(1)); - final iconDecoration = iconContainer.decoration as BoxDecoration?; - - expect(iconDecoration, isNotNull); - expect(iconDecoration!.borderRadius, BorderRadius.circular(12)); - expect(iconContainer.constraints?.maxWidth, 40); - expect(iconContainer.constraints?.maxHeight, 40); - }); - }); -} \ No newline at end of file diff --git a/test/core/widgets/help_sheet/help_sheet_test.dart b/test/core/widgets/help_sheet/help_sheet_test.dart new file mode 100644 index 0000000..99996b1 --- /dev/null +++ b/test/core/widgets/help_sheet/help_sheet_test.dart @@ -0,0 +1,330 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../../app_icon_finder.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/utils/br_number_format.dart'; +import 'package:real_calc/core/widgets/app_sheet.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/help_sheet/help_content.dart'; +import 'package:real_calc/core/widgets/help_sheet/help_sheet.dart'; +import 'package:real_calc/core/widgets/help_sheet/methodology.dart'; + +void main() { + Future pump( + WidgetTester tester, { + CalculatorKind kind = CalculatorKind.financing, + Size size = const Size(400, 800), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: Scaffold( + body: CalculatorScope( + kind: kind, + child: Builder( + builder: (context) => Align( + alignment: Alignment.topCenter, + child: CalculatorBand( + title: 'Calculadora', + statement: const BandStatement(value: 'x'), + onHelp: () => showHelpSheet(context, kind), + ), + ), + ), + ), + ), + ), + ); + } + + Future open(WidgetTester tester) async { + await tester.tap(findAppIcon(AppIcons.help)); + await tester.pumpAndSettle(); + } + + final sheet = find.byType(AppSheet); + + group('HelpSheet', () { + testWidgets('abre pelo "?" com o conteúdo da calculadora', (tester) async { + await pump(tester); + await open(tester); + + const content = HelpContent.financing; + expect(find.text(helpSheetTitle), findsOneWidget); + expect(find.text(content.intro), findsOneWidget); + for (final field in content.fields) { + expect(find.text(field.name), findsOneWidget); + expect(find.text(field.description), findsOneWidget); + } + expect(find.text(content.exampleResult), findsOneWidget); + expect(find.text(content.formula), findsOneWidget); + expect(find.text('Entendi'), findsOneWidget); + expect(find.byType(Icon).evaluate().length, lessThanOrEqualTo(3)); + }); + + testWidgets('topo a 96px', (tester) async { + await pump(tester, size: const Size(400, 640)); + await open(tester); + + expect(tester.getTopLeft(sheet).dy, greaterThanOrEqualTo(96)); + }); + + testWidgets('exemplo e "Entendi" na cor da calculadora', (tester) async { + for (final kind in CalculatorKind.values) { + await pump(tester, kind: kind); + await open(tester); + final palette = CalculatorPalette.of(kind); + + final example = tester.widget( + find.text(HelpContent.of(kind).exampleResult), + ); + expect(example.style!.color, palette.text); + final button = tester.widget(find.byType(FilledButton)); + expect(button.style!.backgroundColor!.resolve({}), palette.solid); + + await tester.tap(find.text('Entendi')); + await tester.pumpAndSettle(); + } + }); + + group('fecha', () { + Future expectCloses( + WidgetTester tester, + Future Function() action, + ) async { + await pump(tester); + await open(tester); + expect(sheet, findsOneWidget); + await action(); + await tester.pumpAndSettle(); + expect(sheet, findsNothing); + } + + testWidgets('no X', (tester) async { + await expectCloses( + tester, + () => tester.tap(find.bySemanticsLabel(AppSheet.closeLabel)), + ); + }); + + testWidgets('em "Entendi"', (tester) async { + await expectCloses(tester, () => tester.tap(find.text('Entendi'))); + }); + + testWidgets('tocando fora', (tester) async { + await expectCloses(tester, () => tester.tapAt(const Offset(20, 20))); + }); + + testWidgets('arrastando para baixo', (tester) async { + await expectCloses( + tester, + () => tester.fling( + find.text(helpSheetTitle), + const Offset(0, 500), + 2000, + ), + ); + }); + + testWidgets('com o voltar do Android', (tester) async { + await expectCloses(tester, () => tester.binding.handlePopRoute()); + }); + }); + + testWidgets('em 640dp, o conteúdo rola e "Entendi" continua visível', ( + tester, + ) async { + await pump(tester, size: const Size(360, 640)); + await open(tester); + + Rect understood() => tester.getRect(find.text('Entendi')); + expect(understood().bottom, lessThanOrEqualTo(640)); + + final scrollable = find.descendant( + of: sheet, + matching: find.byType(Scrollable), + ); + expect( + tester.state(scrollable).position.maxScrollExtent, + greaterThan(0), + ); + + await tester.scrollUntilVisible( + find.text(HelpContent.financing.formula), + 100, + scrollable: scrollable, + ); + expect(find.text(HelpContent.financing.formula).hitTestable(), findsOne); + expect(understood().bottom, lessThanOrEqualTo(640)); + }); + + testWidgets('é anunciada como diálogo', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester); + await open(tester); + + expect( + tester.getSemantics(find.bySemanticsLabel('Dialog')), + isSemantics(namesRoute: true, scopesRoute: true), + ); + handle.dispose(); + }); + + testWidgets('devolve o foco ao "?" ao fechar', (tester) async { + await pump(tester); + final helpFocus = Focus.of(tester.element(findAppIcon(AppIcons.help))); + helpFocus.requestFocus(); + await tester.pump(); + + await open(tester); + expect(helpFocus.hasFocus, isFalse); + + await tester.tap(find.text('Entendi')); + await tester.pumpAndSettle(); + expect(helpFocus.hasFocus, isTrue); + }); + }); + + group('Metodologia da Correção', () { + const methodology = Methodology.correction; + Finder scrollable() => find.descendant( + of: find.byType(AppSheet), + matching: find.byType(Scrollable), + ); + + testWidgets('vem depois da fórmula, com título e todos os blocos', ( + tester, + ) async { + await pump(tester, kind: CalculatorKind.correction); + await open(tester); + + await tester.scrollUntilVisible( + find.text(methodology.items.last.text), + 200, + scrollable: scrollable(), + ); + + expect(find.text(Methodology.title), findsOneWidget); + for (final item in methodology.items) { + expect(find.text(item.title), findsOneWidget); + expect(find.text(item.text), findsOneWidget); + } + final formula = tester.getRect(find.text(HelpContent.correction.formula)); + final title = tester.getRect(find.text(Methodology.title)); + expect(title.top, greaterThan(formula.bottom)); + expect( + tester.getRect(find.text(methodology.items.last.text)).bottom, + lessThanOrEqualTo(tester.getRect(find.text('Entendi')).top), + ); + }); + + testWidgets('as outras calculadoras não têm a seção', (tester) async { + for (final kind in [ + CalculatorKind.financing, + CalculatorKind.deposits, + CalculatorKind.futureValue, + ]) { + await pump(tester, kind: kind); + await open(tester); + expect(find.text(Methodology.title, skipOffstage: false), findsNothing); + await tester.tap(find.text('Entendi')); + await tester.pumpAndSettle(); + } + }); + + testWidgets('rola até o fim sem estourar em 360dp com fonte em 130%', ( + tester, + ) async { + await pump( + tester, + kind: CalculatorKind.correction, + size: const Size(360, 640), + textScale: 1.3, + ); + await open(tester); + + await tester.scrollUntilVisible( + find.text(methodology.items.last.text), + 200, + scrollable: scrollable(), + ); + + expect(tester.takeException(), isNull); + expect(find.text(methodology.items.last.text).hitTestable(), findsOne); + expect( + tester.getRect(find.text('Entendi')).bottom, + lessThanOrEqualTo(640), + ); + }); + + testWidgets('os títulos são cabeçalhos, na ordem da tela', (tester) async { + final handle = tester.ensureSemantics(); + await pump( + tester, + kind: CalculatorKind.correction, + size: const Size(400, 4000), + ); + await open(tester); + + final titles = [ + Methodology.title, + for (final item in methodology.items) item.title, + ]; + double? previous; + for (final title in titles) { + expect( + tester.getSemantics(find.text(title)), + isSemantics(label: title, isHeader: true), + ); + final top = tester.getRect(find.text(title)).top; + if (previous != null) expect(top, greaterThan(previous)); + previous = top; + } + handle.dispose(); + }); + + test('o exemplo do fator acumulado confere', () { + expect(1.01 * 1.02, closeTo(1.0302, 1e-12)); + expect(methodology.items.first.text, contains('1,0302')); + }); + }); + + group('HelpContent', () { + double price(double v, double i, int n) => v * i / (1 - pow(1 + i, -n)); + + String money(num value) => formatCents( + toCents(value.toDouble()), + ).replaceAll(String.fromCharCode(0xA0), ' '); + + test('os exemplos batem com as fórmulas', () { + expect(HelpContent.financing.exampleResult, money(price(2500, 0.02, 12))); + expect( + HelpContent.deposits.exampleResult, + money(100 * 1.01 * (pow(1.01, 12) - 1) / 0.01), + ); + expect( + HelpContent.futureValue.exampleResult, + money(1000 * pow(1.01, 12)), + ); + expect(HelpContent.correction.exampleResult, money(1000 * 1.005 * 1.004)); + }); + }); +} diff --git a/test/core/widgets/input_forms_result_card_test.dart b/test/core/widgets/input_forms_result_card_test.dart deleted file mode 100644 index d7aba3c..0000000 --- a/test/core/widgets/input_forms_result_card_test.dart +++ /dev/null @@ -1,427 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/input_forms_result_card_theme.dart'; -import 'package:real_calc/core/widgets/types/input_field_state.dart'; -import 'package:real_calc/core/widgets/input_forms_result_card.dart'; - -void main() { - final testTheme = InputFormsResultCardTheme( - neutralIconColor: Colors.grey, - neutralBorderColor: Colors.grey.shade300, - neutralLabelColor: Colors.black54, - highlightedColor: Colors.orange, - calculatedColor: Colors.blue, - errorColor: Colors.red, - borderRadius: 12, - badgeStyle: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), - ); - - Widget buildTestWidget({ - required TextEditingController controller, - String label = 'Valor', - String hint = 'Digite o valor', - IconData icon = Icons.attach_money, - InputFieldState state = InputFieldState.neutral, - String? Function(String?)? validator, - List? inputFormatters, - VoidCallback? onTap, - String? trailingBadge, - }) { - return MaterialApp( - theme: ThemeData(extensions: [testTheme]), - home: Scaffold( - body: InputFormsResultCard( - controller: controller, - label: label, - hint: hint, - icon: icon, - state: state, - validator: validator, - inputFormatters: inputFormatters, - onTap: onTap, - trailingBadge: trailingBadge, - ), - ), - ); - } - - group('InputFormsResultCard', () { - late TextEditingController controller; - - setUp(() { - controller = TextEditingController(); - }); - - tearDown(() { - controller.dispose(); - }); - - testWidgets('renderiza label, hint e ícone corretamente', (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - label: 'Preço do imóvel', - hint: 'R\$ 0,00', - icon: Icons.home, - ), - ); - - expect(find.text('Preço do imóvel'), findsOneWidget); - expect(find.byIcon(Icons.home), findsOneWidget); - - final textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'R\$ 0,00'); - }); - - testWidgets('não exibe badge quando o estado é neutro', (tester) async { - await tester.pumpWidget( - buildTestWidget(controller: controller, state: InputFieldState.neutral), - ); - - expect(find.text('vazio'), findsNothing); - expect(find.text('calculado'), findsNothing); - }); - - testWidgets('exibe badge "vazio" quando o estado é highlighted', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - state: InputFieldState.highlighted, - ), - ); - - expect(find.text('vazio'), findsOneWidget); - }); - - testWidgets('exibe badge "calculado" quando o estado é calculated', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - state: InputFieldState.calculated, - ), - ); - - expect(find.text('calculado'), findsOneWidget); - }); - - testWidgets('não exibe badge quando o estado é error', (tester) async { - await tester.pumpWidget( - buildTestWidget(controller: controller, state: InputFieldState.error), - ); - - expect(find.text('vazio'), findsNothing); - expect(find.text('calculado'), findsNothing); - }); - - testWidgets('prioriza trailingBadge quando ele for informado', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - state: InputFieldState.neutral, - trailingBadge: 'opcional', - ), - ); - - expect(find.text('opcional'), findsOneWidget); - }); - - testWidgets('mostra a mensagem de erro vinda do validator quando o campo falha', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - state: InputFieldState.error, - validator: (value) => value == null || value.isEmpty ? 'Obrigatório' : null, - ), - ); - - controller.text = ''; - await tester.pump(); - - final textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.errorText, 'Obrigatório'); - }); - - testWidgets('aplica a cor de destaque correta ao ícone conforme o estado', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - icon: Icons.percent, - state: InputFieldState.calculated, - ), - ); - - final iconWidget = tester.widget(find.byIcon(Icons.percent)); - expect(iconWidget.color, testTheme.calculatedColor); - }); - - testWidgets('usa a borda neutra e fina quando o estado é neutro', ( - tester, - ) async { - await tester.pumpWidget( - buildTestWidget(controller: controller, state: InputFieldState.neutral), - ); - - final container = tester.widget(find.byType(Container).first); - final decoration = container.decoration as BoxDecoration; - final border = decoration.border as Border; - - expect(border.top.width, 0.5); - expect(decoration.color, isNull); - }); - - testWidgets( - 'usa borda destacada e fundo tintado quando o estado não é neutro', - (tester) async { - await tester.pumpWidget( - buildTestWidget(controller: controller, state: InputFieldState.error), - ); - - final container = tester.widget( - find.byType(Container).first, - ); - final decoration = container.decoration as BoxDecoration; - final border = decoration.border as Border; - - expect(border.top.width, 1); - expect(decoration.color, isNotNull); - }, - ); - - testWidgets('permite digitar texto e atualiza o controller', ( - tester, - ) async { - await tester.pumpWidget(buildTestWidget(controller: controller)); - - await tester.enterText(find.byType(TextFormField), '1500'); - await tester.pump(); - - expect(controller.text, '1500'); - }); - - testWidgets('chama onTap ao tocar no campo', (tester) async { - var tapped = false; - - await tester.pumpWidget( - buildTestWidget(controller: controller, onTap: () => tapped = true), - ); - - await tester.tap(find.byType(TextFormField)); - await tester.pump(); - - expect(tapped, isTrue); - }); - - testWidgets('valida o campo e exibe mensagem de erro do validator', ( - tester, - ) async { - final formKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(extensions: [testTheme]), - home: Scaffold( - body: Form( - key: formKey, - child: InputFormsResultCard( - controller: controller, - label: 'Valor', - hint: 'Digite o valor', - icon: Icons.attach_money, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Campo obrigatório'; - } - return null; - }, - ), - ), - ), - ), - ); - - formKey.currentState!.validate(); - await tester.pump(); - - expect(find.text('Campo obrigatório'), findsOneWidget); - }); - - testWidgets('respeita os inputFormatters ao digitar', (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - ), - ); - - await tester.enterText(find.byType(TextFormField), 'a1b2c3'); - await tester.pump(); - - expect(controller.text, '123'); - }); - - testWidgets('usa teclado numérico com casas decimais', (tester) async { - await tester.pumpWidget(buildTestWidget(controller: controller)); - - final textField = tester.widget(find.byType(TextField)); - expect( - textField.keyboardType, - const TextInputType.numberWithOptions(decimal: true), - ); - }); - - group("hint dinâmico", () { - testWidgets( - 'exibe hint "toque para calcular" quando estado é highlighted e campo vazio', - (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.highlighted, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'toque para calcular'); - }, - ); - - testWidgets( - 'mantém o hint original quando estado é highlighted mas o campo tem texto', - (tester) async { - controller.text = '1500'; - - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.highlighted, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'Digite o valor'); - }, - ); - - testWidgets( - 'mantém o hint original quando o campo está vazio mas o estado não é highlighted', - (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.neutral, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'Digite o valor'); - }, - ); - - testWidgets( - 'aplica a cor de destaque (accent) no hint quando é mensagem "toque para calcular"', - (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.highlighted, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - final hintStyle = textField.decoration?.hintStyle; - - expect(hintStyle?.color, testTheme.highlightedColor); - }, - ); - - testWidgets( - 'aplica a cor neutra no hint quando não é mensagem "toque para calcular"', - (tester) async { - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.neutral, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - final hintStyle = textField.decoration?.hintStyle; - - expect(hintStyle?.color, testTheme.neutralLabelColor); - }, - ); - - testWidgets( - 'aplica a cor neutra no hint quando o estado é highlighted mas o campo tem texto', - (tester) async { - controller.text = '1500'; - - await tester.pumpWidget( - buildTestWidget( - controller: controller, - hint: 'Digite o valor', - state: InputFieldState.highlighted, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - final hintStyle = textField.decoration?.hintStyle; - - expect(hintStyle?.color, testTheme.neutralLabelColor); - }, - ); - - testWidgets('o hint muda quando onTap altera o estado para calculado', ( - tester, - ) async { - InputFieldState state = InputFieldState.highlighted; - - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(extensions: [testTheme]), - home: Scaffold( - body: StatefulBuilder( - builder: (context, setState) { - return InputFormsResultCard( - controller: controller, - label: 'Valor', - hint: 'Digite o valor', - icon: Icons.attach_money, - state: state, - onTap: () => - setState(() => state = InputFieldState.calculated), - ); - }, - ), - ), - ), - ); - - var textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'toque para calcular'); - - await tester.tap(find.byType(TextFormField)); - await tester.pump(); - - textField = tester.widget(find.byType(TextField)); - expect(textField.decoration?.hintText, 'Digite o valor'); - }); - }); - }); -} diff --git a/test/core/widgets/input_forms_test.dart b/test/core/widgets/input_forms_test.dart deleted file mode 100644 index b962a0e..0000000 --- a/test/core/widgets/input_forms_test.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/app_theme.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/widgets/input_forms.dart'; - -void main() { - late TextEditingController controller; - late String label; - late String hint; - late Widget prefixIcon; - late Widget suffixIcon; - final formKey = GlobalKey(); - - setUp(() { - controller = TextEditingController(); - label = 'Meu Label'; - hint = 'Digite algo'; - prefixIcon = const Icon(Icons.attach_money); - suffixIcon = const Icon(Icons.check); - }); - - tearDown(() { - controller.dispose(); - }); - - Widget createSut({ - TextInputType keyboardType = TextInputType.number, - TextInputAction? textInputAction, - GestureTapCallback? onTap, - bool includeSuffix = true, - String? Function(String?)? validator, - }) { - return MaterialApp( - home: Scaffold( - body: Form( - key: formKey, - child: InputForms( - label: label, - hint: hint, - controller: controller, - prefixIcon: prefixIcon, - suffixIcon: includeSuffix ? suffixIcon : null, - keyboardType: keyboardType, - textInputAction: textInputAction, - onTap: onTap, - validator: validator, // Injeção do validator - ), - ), - ), - ); - } - - group('InputForms', () { - testWidgets('Deve exibir o label e o hint text corretamente', ( - tester, - ) async { - await tester.pumpWidget(createSut()); - - final labelFinder = find.text(label); - expect(labelFinder, findsOneWidget); - - final Text labelText = tester.widget(labelFinder); - expect(labelText.style?.fontSize, 14); - expect(labelText.style?.fontWeight, FontWeight.w600); - - final textFieldFinder = find.byType(TextField); - final TextField textField = tester.widget(textFieldFinder); - - expect(textField.decoration?.hintText, hint); - }); - - testWidgets('Deve conter o prefixIcon e o suffixIcon configurados', ( - tester, - ) async { - await tester.pumpWidget(createSut()); - - expect(find.byIcon(Icons.attach_money), findsOneWidget); - expect(find.byIcon(Icons.check), findsOneWidget); - }); - - testWidgets('Não deve quebrar se o suffixIcon for nulo', (tester) async { - await tester.pumpWidget(createSut(includeSuffix: false)); - - expect(find.byIcon(Icons.attach_money), findsOneWidget); - expect(find.byIcon(Icons.check), findsNothing); - }); - - testWidgets('Deve aplicar as configurações de teclado e ação corretas', ( - tester, - ) async { - await tester.pumpWidget( - createSut( - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - ), - ); - - final textField = tester.widget(find.byType(TextField)); - - expect(textField.keyboardType, TextInputType.emailAddress); - expect(textField.textInputAction, TextInputAction.done); - }); - - testWidgets('Deve disparar a função onTap ao clicar no campo', ( - tester, - ) async { - int tapCount = 0; - - await tester.pumpWidget(createSut(onTap: () => tapCount++)); - - await tester.tap(find.byType(TextFormField)); - await tester.pump(); - - expect(tapCount, 1); - }); - - testWidgets('Deve aceitar entrada de texto e atualizar o controller', ( - tester, - ) async { - await tester.pumpWidget(createSut()); - - await tester.enterText(find.byType(TextFormField), '123.45'); - await tester.pump(); - - expect(controller.text, '123.45'); - }); - - test('AppTheme define corretamente as bordas do InputDecoration', () { - final theme = AppTheme.darkTheme; - final decoration = theme.inputDecorationTheme; - - expect(decoration.enabledBorder, isA()); - final enabled = decoration.enabledBorder as OutlineInputBorder; - expect(enabled.borderSide.color, ColorTokens.background); - expect(enabled.borderSide.width, 1.0); - expect(enabled.borderRadius, BorderRadius.circular(5)); - - expect(decoration.focusedBorder, isA()); - final focused = decoration.focusedBorder as OutlineInputBorder; - expect(focused.borderSide.color, ColorTokens.background); - expect(focused.borderSide.width, 1.5); - expect(focused.borderRadius, BorderRadius.circular(5)); - }); - - testWidgets( - 'Deve exibir a mensagem de erro na tela quando o validator falhar', - (tester) async { - const errorText = 'Este campo possui um valor inválido'; - - await tester.pumpWidget( - createSut( - validator: (value) { - if (value == null || value.isEmpty) { - return errorText; - } - return null; - }, - ), - ); - - expect(find.text(errorText), findsNothing); - - formKey.currentState?.validate(); - await tester.pump(); - - expect(find.text(errorText), findsOneWidget); - }, - ); - }); -} diff --git a/test/core/widgets/opitions_bottom_forms_test.dart b/test/core/widgets/opitions_bottom_forms_test.dart deleted file mode 100644 index 49c95d1..0000000 --- a/test/core/widgets/opitions_bottom_forms_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/options_bottom_forms_theme.dart'; -import 'package:real_calc/core/widgets/options_bottom_forms.dart'; - -void main() { - Widget createSut({ - VoidCallback? onCalculate, - VoidCallback? onClear, - VoidCallback? onShare, - String calculateLabel = 'Calcular', - Color calculateBackground = const Color(0xFF1E94F6), - Color calculateForeground = Colors.white, - Color actionBackground = const Color(0xFF1A222D), - Color actionForeground = Colors.white70, - Color borderButtonColor = Colors.white70, - }) { - return MaterialApp( - theme: ThemeData( - extensions: [ - OptionsBottomFormsTheme( - calculateButtonBackground: calculateBackground, - calculateButtonForeground: calculateForeground, - actionButtonBackground: actionBackground, - actionButtonForeground: actionForeground, - borderButtonColor: borderButtonColor, - ), - ], - ), - home: Scaffold( - body: OptionsBottomForms( - onCalculate: onCalculate, - onClear: onClear, - calculateLabel: calculateLabel, - ), - ), - ); - } - - group('OptionsBottomForms', () { - testWidgets( - 'Deve renderizar os três botões com os textos e ícones corretos', - (tester) async { - await tester.pumpWidget(createSut()); - - expect(find.text('Calcular'), findsOneWidget); - expect(find.text('Limpar'), findsOneWidget); - }, - ); - - testWidgets( - 'Deve aplicar as cores corretas nos botões baseadas no design', - (tester) async { - await tester.pumpWidget(createSut()); - - final buttons = tester - .widgetList(find.byType(ElevatedButton)) - .toList(); - expect(buttons, hasLength(2)); - - final calculateColor = buttons[0].style?.backgroundColor?.resolve({}); - expect(calculateColor, const Color(0xFF1E94F6)); - - final clearColor = buttons[1].style?.backgroundColor?.resolve({}); - const darkColor = Color(0xFF1A222D); - expect(clearColor, darkColor); - }, - ); - - testWidgets('Deve aceitar um texto customizado para o botão de cálculo', ( - tester, - ) async { - await tester.pumpWidget(createSut(calculateLabel: 'Corrigir valor')); - - expect(find.text('Corrigir valor'), findsOneWidget); - expect(find.text('Calcular'), findsNothing); - }); - - testWidgets( - 'Deve disparar os respectivos callbacks ao clicar em cada botão', - (tester) async { - int calculateClicks = 0; - int clearClicks = 0; - int shareClicks = 0; - - await tester.pumpWidget( - createSut( - onCalculate: () => calculateClicks++, - onClear: () => clearClicks++, - onShare: () => shareClicks++, - ), - ); - - await tester.tap(find.text('Calcular')); - await tester.pump(); - - await tester.tap(find.text('Limpar')); - await tester.pump(); - - expect(calculateClicks, 1); - expect(clearClicks, 1); - }, - ); - }); -} diff --git a/test/core/widgets/result_details/goldens/correction_dark.png b/test/core/widgets/result_details/goldens/correction_dark.png new file mode 100644 index 0000000..d6cae0d Binary files /dev/null and b/test/core/widgets/result_details/goldens/correction_dark.png differ diff --git a/test/core/widgets/result_details/goldens/correction_light.png b/test/core/widgets/result_details/goldens/correction_light.png new file mode 100644 index 0000000..85b4a34 Binary files /dev/null and b/test/core/widgets/result_details/goldens/correction_light.png differ diff --git a/test/core/widgets/result_details/goldens/financing_dark.png b/test/core/widgets/result_details/goldens/financing_dark.png new file mode 100644 index 0000000..bef1628 Binary files /dev/null and b/test/core/widgets/result_details/goldens/financing_dark.png differ diff --git a/test/core/widgets/result_details/goldens/financing_light.png b/test/core/widgets/result_details/goldens/financing_light.png new file mode 100644 index 0000000..d65ef1b Binary files /dev/null and b/test/core/widgets/result_details/goldens/financing_light.png differ diff --git a/test/core/widgets/result_details/result_details_test.dart b/test/core/widgets/result_details/result_details_test.dart new file mode 100644 index 0000000..a8c4959 --- /dev/null +++ b/test/core/widgets/result_details/result_details_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/themes/calculator_scope.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/result_summaries.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/period.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; +import 'package:real_calc/modules/value_correction/presentation/utils/correction_result_summary.dart'; + +final financing = financingResultSummary( + FinancialCalculation( + initialValue: 2500, + rate: 2, + periods: 12, + finalValue: 236.3990, + ), +); + +final correction = correctionResultSummary( + ValueCorrection( + index: 433, + period: Period(initial: DateTime(2022), end: DateTime(2025)), + percentage: 100, + originalValue: 1000, + factor: 1.2345678, + adjustedValue: 1234.5678, + variation: 23.45678, + ), +); + +/// Carrega a Manrope para os goldens mostrarem o texto de verdade, e não os +/// blocos da fonte padrão de teste. +Future loadManrope() async { + final loader = FontLoader('Manrope'); + for (final weight in ['Medium', 'SemiBold', 'Bold', 'ExtraBold']) { + loader.addFont(rootBundle.load('assets/fonts/Manrope-$weight.ttf')); + } + await loader.load(); +} + +void main() { + setUpAll(loadManrope); + + Future pump( + WidgetTester tester, + ResultSummary summary, { + CalculatorKind kind = CalculatorKind.financing, + Brightness brightness = Brightness.light, + double width = 360, + }) async { + tester.view.physicalSize = Size(width, 480); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + theme: brightness == Brightness.light + ? AppTheme.lightTheme + : AppTheme.darkTheme, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CalculatorScope( + kind: kind, + child: Align( + alignment: Alignment.topCenter, + child: ResultDetails(summary: summary), + ), + ), + ), + ), + ), + ); + } + + group('ResultDetails', () { + testWidgets('mostra título, legenda e linhas de detalhe', (tester) async { + await pump(tester, financing); + + expect(find.text('Do total que você vai pagar'), findsOneWidget); + expect(find.text('Emprestado 88%'), findsOneWidget); + expect(find.text('Juros 12%'), findsOneWidget); + for (final line in financing.lines) { + expect(find.text(line.label), findsOneWidget); + expect(find.text(line.value), findsOneWidget); + } + expect(find.text(financing.lines.first.value), findsOneWidget); + expect(financing.lines.first.value, contains('2.836,80')); + }); + + testWidgets('barra de 6px com a cor da pessoa e a da calculadora', ( + tester, + ) async { + await pump(tester, financing); + + final boxes = tester + .widgetList( + find.descendant( + of: find.byType(ResultDetails), + matching: find.byType(ColoredBox), + ), + ) + .map((box) => box.color) + .toList(); + expect(boxes, [ + AppColors.light.textPrimary, + CalculatorPalette.of(CalculatorKind.financing).solid, + ]); + + final bar = find.descendant( + of: find.byType(ResultDetails), + matching: find.byType(ClipRRect), + ); + expect(tester.getSize(bar).height, ResultDetails.barHeight); + // Cada segmento ocupa a altura toda da barra, na proporção 88/12. + final segments = find.descendant( + of: bar, + matching: find.byType(ColoredBox), + ); + final own = tester.getSize(segments.at(0)); + final other = tester.getSize(segments.at(1)); + expect(own.height, ResultDetails.barHeight); + expect(other.height, ResultDetails.barHeight); + expect(own.width / (own.width + other.width), closeTo(0.88, 0.01)); + }); + + testWidgets('linhas de detalhe têm pelo menos 32px', (tester) async { + await pump(tester, financing); + + final row = find + .ancestor( + of: find.text('Total pago'), + matching: find.byType(ConstrainedBox), + ) + .first; + expect( + tester.getSize(row).height, + greaterThanOrEqualTo(ResultDetails.lineHeight), + ); + }); + + testWidgets('a barra tem Semantics com a composição', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, financing); + + expect( + find.bySemanticsLabel('Emprestado 88%, Juros 12%'), + findsOneWidget, + ); + handle.dispose(); + }); + + testWidgets('sem composição, mostra só título e linhas', (tester) async { + await pump( + tester, + const ResultSummary( + title: 'Do valor corrigido', + composition: null, + lines: [ResultDetailLine('Fator de correção', '0,9900000')], + ), + ); + + expect( + find.descendant( + of: find.byType(ResultDetails), + matching: find.byType(ClipRRect), + ), + findsNothing, + ); + expect(find.text('Fator de correção'), findsOneWidget); + }); + + testWidgets('valores com 7 dígitos cabem em 360dp', (tester) async { + final summary = financingResultSummary( + FinancialCalculation( + initialValue: 1500000, + rate: 1.5, + periods: 360, + finalValue: 22839.53, + ), + ); + expect(summary.lines.first.value, contains('8.222.230,80')); + + await pump(tester, summary); + + expect(tester.takeException(), isNull); + for (final line in summary.lines) { + final value = tester.getRect(find.text(line.value)); + expect(value.right, lessThanOrEqualTo(360 - 24)); + } + }); + }); + + group('goldens', () { + for (final brightness in Brightness.values) { + testWidgets('financiamento no tema ${brightness.name}', (tester) async { + await pump(tester, financing, brightness: brightness); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('goldens/financing_${brightness.name}.png'), + ); + }); + + testWidgets('correção no tema ${brightness.name}', (tester) async { + await pump( + tester, + correction, + kind: CalculatorKind.correction, + brightness: brightness, + ); + expect(find.text('Valor original 81%'), findsOneWidget); + expect(find.text('Correção 19%'), findsOneWidget); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile('goldens/correction_${brightness.name}.png'), + ); + }); + } + }); +} diff --git a/test/core/widgets/theme_toggle_button_test.dart b/test/core/widgets/theme_toggle_button_test.dart new file mode 100644 index 0000000..3f17370 --- /dev/null +++ b/test/core/widgets/theme_toggle_button_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../app_icon_finder.dart'; +import 'package:real_calc/core/themes/theme_cubit.dart'; +import 'package:real_calc/core/widgets/theme_toggle_button.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late ThemeCubit cubit; + + setUp(() async { + SharedPreferences.setMockInitialValues({ThemeCubit.storageKey: 'dark'}); + cubit = ThemeCubit(await SharedPreferences.getInstance()); + }); + + tearDown(() => cubit.close()); + + Widget app() => BlocProvider.value( + value: cubit, + child: BlocBuilder( + builder: (context, mode) => MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: mode, + home: const Scaffold(body: Center(child: ThemeToggleButton())), + ), + ), + ); + + group('ThemeToggleButton', () { + testWidgets('no escuro mostra o sol e o rótulo para o modo claro', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(app()); + + expect(findAppIcon(AppIcons.sun), findsOneWidget); + expect( + tester.getSemantics(find.byType(ThemeToggleButton)), + isSemantics( + label: 'Mudar para o modo claro', + isButton: true, + hasTapAction: true, + ), + ); + semantics.dispose(); + }); + + testWidgets('o toque troca o tema na hora e grava a escolha', ( + tester, + ) async { + await tester.pumpWidget(app()); + + await tester.tap(find.byType(ThemeToggleButton)); + await tester.pumpAndSettle(); + + expect(cubit.state, ThemeMode.light); + expect( + Theme.of(tester.element(find.byType(IconButton))).brightness, + Brightness.light, + ); + expect(findAppIcon(AppIcons.moon), findsOneWidget); + expect(find.bySemanticsLabel('Mudar para o modo escuro'), findsOneWidget); + + final preferences = await SharedPreferences.getInstance(); + expect(preferences.getString(ThemeCubit.storageKey), 'light'); + }); + + testWidgets('tem área de toque de 44x44', (tester) async { + await tester.pumpWidget(app()); + + expect( + tester.getSize(find.byType(ThemeToggleButton)), + const Size.square(44), + ); + }); + }); +} diff --git a/test/core/widgets/title_widget_test.dart b/test/core/widgets/title_widget_test.dart deleted file mode 100644 index 2397b98..0000000 --- a/test/core/widgets/title_widget_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/title_widget_theme.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; - -void main() { - late String title; - late String subtitle; - late Widget sut; - - setUpAll(() {}); - - setUp(() { - title = "Título do app"; - subtitle = "subtítulo do app"; - - sut = MaterialApp( - theme: ThemeData( - extensions: const [ - TitleWidgetTheme( - titleStyle: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - subtitleStyle: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF94A3B8), - ), - ), - ], - ), - home: Scaffold( - body: TitleWidget(title: title, subtitle: subtitle), - ), - ); - }); - - group("TitleWidget", () { - testWidgets("Deve conter uma Column no TitleWidget", (tester) async { - await tester.pumpWidget(sut); - expect(find.byType(Column), findsOneWidget); - }); - - testWidgets( - "Deve conter um Column com spacing 8 e crossAxisAlignment start", - (tester) async { - await tester.pumpWidget(sut); - final column = tester.widget(find.byType(Column)); - expect(column.spacing, 8); - expect(column.crossAxisAlignment, CrossAxisAlignment.start); - }, - ); - - testWidgets("Deve conter duas strings dentro de Column", (tester) async { - await tester.pumpWidget(sut); - expect( - find.descendant( - of: find.byType(Column), - matching: find.byType(Text), - ), - findsNWidgets(2), - ); - }); - - testWidgets("Deve mostrar o title e o subtitle", (tester) async { - await tester.pumpWidget(sut); - expect(find.text(title), findsOneWidget); - expect(find.text(subtitle), findsOneWidget); - }); - - testWidgets( - "O title deve conter estilo correto (fontSize 24, bold, branco)", - (tester) async { - await tester.pumpWidget(sut); - - final textWidget = tester.widget(find.text(title)); - expect(textWidget.style?.fontSize, 24); - expect(textWidget.style?.fontWeight, FontWeight.bold); - expect(textWidget.style?.color, Colors.white); - }, - ); - - testWidgets( - "O subtitle deve conter estilo correto (fontSize 14, w500, cor #94A3B8)", - (tester) async { - await tester.pumpWidget(sut); - - final textWidget = tester.widget(find.text(subtitle)); - expect(textWidget.style?.fontSize, 14); - expect(textWidget.style?.fontWeight, FontWeight.w500); - expect(textWidget.style?.color, const Color(0xFF94A3B8)); - }, - ); - }); -} \ No newline at end of file diff --git a/test/core/widgets/warning_banner_test.dart b/test/core/widgets/warning_banner_test.dart deleted file mode 100644 index 2104e04..0000000 --- a/test/core/widgets/warning_banner_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/warning_banner_theme.dart'; -import 'package:real_calc/core/widgets/warning_banner.dart'; - -void main() { - const warningTheme = WarningBannerTheme( - backgroundColor: Color(0x33224466), - borderColor: Color(0xFF224466), - iconColor: Color(0xFFFFAA00), - textStyle: TextStyle(fontSize: 14, color: Colors.white), - padding: EdgeInsets.all(16), - borderRadius: 10, - iconSize: 20, - iconTextSpacing: 8, - ); - - Widget buildSut(String message) { - return MaterialApp( - theme: ThemeData(extensions: [warningTheme]), - home: Scaffold(body: WarningBanner(message: message)), - ); - } - - group('WarningBanner', () { - testWidgets('mostra a mensagem e o ícone de aviso', (tester) async { - await tester.pumpWidget(buildSut('Período indisponível')); - - expect(find.text('Período indisponível'), findsOneWidget); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - }); - - testWidgets('aplica o tema visual configurado', (tester) async { - await tester.pumpWidget(buildSut('Aviso de teste')); - - final container = tester.widget( - find - .ancestor( - of: find.text('Aviso de teste'), - matching: find.byType(Container), - ) - .first, - ); - final decoration = container.decoration! as BoxDecoration; - final icon = tester.widget( - find.byIcon(Icons.warning_amber_rounded), - ); - final text = tester.widget(find.text('Aviso de teste')); - - expect(container.padding, const EdgeInsets.all(16)); - expect(decoration.color, warningTheme.backgroundColor); - expect(decoration.borderRadius, BorderRadius.circular(10)); - expect( - (decoration.border! as Border).top.color, - warningTheme.borderColor, - ); - expect(icon.color, warningTheme.iconColor); - expect(icon.size, 20); - expect(text.style, warningTheme.textStyle); - }); - - test('WarningBannerTheme suporta copyWith e interpolação', () { - final copied = warningTheme.copyWith(borderRadius: 14); - final interpolated = warningTheme.lerp(copied, 0.5); - - expect(copied.borderRadius, 14); - expect(interpolated.borderRadius, 12); - }); - }); -} diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart index 9d5b0a5..c24855e 100644 --- a/test/flutter_test_config.dart +++ b/test/flutter_test_config.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:google_fonts/google_fonts.dart'; import 'package:mocktail/mocktail.dart'; import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; @@ -9,8 +8,6 @@ import 'fake_financing.dart'; Future testExecutable(FutureOr Function() testMain) async { - GoogleFonts.config.allowRuntimeFetching = false; - registerFallbackValue(FinancialCalculationTarget.initialValue); registerFallbackValue(FakeFinancing()); registerFallbackValue(FakeFinancialCalculation()); diff --git a/test/modules/app_widget_test.dart b/test/modules/app_widget_test.dart index 1d64759..f838417 100644 --- a/test/modules/app_widget_test.dart +++ b/test/modules/app_widget_test.dart @@ -1,32 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:real_calc/core/themes/theme_cubit.dart'; import 'package:real_calc/modules/app_module.dart'; import 'package:real_calc/modules/app_widget.dart'; import 'package:real_calc/modules/home/presentation/page/page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - setUpAll(() { - GoogleFonts.config.allowRuntimeFetching = false; - }); + late ThemeCubit themeCubit; + + Future buildApp() async { + SharedPreferences.setMockInitialValues({}); + themeCubit = ThemeCubit(await SharedPreferences.getInstance()); + return BlocProvider.value( + value: themeCubit, + child: ModularApp(module: AppModule(), child: const AppWidget()), + ); + } tearDown(() { Modular.destroy(); + themeCubit.close(); }); group('AppWidget & AppModule - Integração de Rotas', () { - testWidgets('Deve inicializar o AppWidget e carregar a HomePage na rota padrão', (tester) async { - await tester.pumpWidget( - ModularApp( - module: AppModule(), - child: const AppWidget(), - ), - ); - - // Aguarda a resolução assíncrona das rotas e animações iniciais - await tester.pumpAndSettle(); - - expect(find.byType(HomePage), findsOneWidget); - }); + testWidgets( + 'Deve inicializar o AppWidget e carregar a HomePage na rota padrão', + (tester) async { + await tester.pumpWidget(await buildApp()); + + // Aguarda a resolução assíncrona das rotas e animações iniciais + await tester.pumpAndSettle(); + + expect(find.byType(HomePage), findsOneWidget); + + // O tema fica no mesmo teste: o Modular não reinicializa bem dois + // ModularApp no mesmo arquivo. + ThemeMode appThemeMode() => + tester.widget(find.byType(MaterialApp)).themeMode!; + + expect(appThemeMode(), ThemeMode.system); + + await themeCubit.setMode(ThemeMode.light); + await tester.pumpAndSettle(); + + expect(appThemeMode(), ThemeMode.light); + expect( + Theme.of(tester.element(find.byType(HomePage))).brightness, + Brightness.light, + ); + }, + ); }); } diff --git a/test/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit_test.dart b/test/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit_test.dart index c36b257..dd97e5c 100644 --- a/test/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit_test.dart +++ b/test/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit_test.dart @@ -41,7 +41,22 @@ void main() { ); expect( cubit.state.stateOf(FinancialCalculationTarget.interestRate), - InputFieldState.highlighted, + InputFieldState.unknown, ); }); + + test('escreve a taxa calculada com duas casas', () { + cubit.applyResult( + FinancialCalculation( + initialValue: 2500, + periods: 12, + rate: 2.0000694603100793, + finalValue: 236.40, + ), + FinancialCalculationTarget.interestRate, + ); + + expect(cubit.rate.text, '2,00'); + expect(cubit.financing.rate, 2); + }); } diff --git a/test/modules/financial_calculators/presentation/pages/financing_page_test.dart b/test/modules/financial_calculators/presentation/pages/financing_page_test.dart index 018f159..485813b 100644 --- a/test/modules/financial_calculators/presentation/pages/financing_page_test.dart +++ b/test/modules/financial_calculators/presentation/pages/financing_page_test.dart @@ -1,82 +1,288 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; import 'package:real_calc/core/themes/app_theme.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/form_notice.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/financing/financing_cubit.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart'; import 'package:real_calc/modules/financial_calculators/presentation/pages/financing_page.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/widgets/forms_view.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; +import 'package:real_calc/modules/financial_calculators/use_case/financing/calculate_financing.dart'; -import '../../../../test_harness.dart'; +class _FakeSharer implements ResultSharer { + final shared = <(String, Rect?)>[]; + @override + Future share(String text, {Rect? origin}) async => + shared.add((text, origin)); +} void main() { - late TestHarness harness; - + late _FakeSharer sharer; + late FinancingCubit financingCubit; + late FinancingFormCubit formCubit; + setUp(() { - harness = TestHarness()..setUpDefaults(); + sharer = _FakeSharer(); + financingCubit = FinancingCubit(CalculateFinancing()); + formCubit = FinancingFormCubit(); + }); + + tearDown(() async { + await financingCubit.close(); + await formCubit.close(); }); - - tearDown(() => harness.dispose()); - - Widget createSut() { - return MaterialApp( - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.dark, - home: MultiBlocProvider( - providers: [ - BlocProvider.value(value: harness.financingCubit), - BlocProvider.value( - value: harness.financingFormCubit, - ), - ], - child: const FinancingPage(), + + Future pump( + WidgetTester tester, { + ThemeMode themeMode = ThemeMode.light, + Size size = const Size(400, 900), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: financingCubit), + BlocProvider.value(value: formCubit), + ], + child: FinancingPage(sharer: sharer), + ), ), ); } - group('FinancingPage - Testes de Integração da Tela', () { - testWidgets('Deve renderizar a árvore de componentes completa com sucesso', (tester) async { - await tester.pumpWidget(createSut()); + Finder row(FinancialCalculationTarget field) => find.byKey(ValueKey(field)); - // 1. Verifica se a estrutura de componentes customizados está presente - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(TitleWidget), findsOneWidget); - expect(find.byType(HelpCard), findsOneWidget); - expect(find.byType(FormsView), findsOneWidget); + Future type( + WidgetTester tester, + FinancialCalculationTarget field, + String text, + ) async { + await tester.enterText( + find.descendant(of: row(field), matching: find.byType(TextField)), + text, + ); + await tester.pump(); + } + Future fillExample(WidgetTester tester) async { + await type(tester, FinancialCalculationTarget.initialValue, '250000'); + await type(tester, FinancialCalculationTarget.periods, '12'); + await type(tester, FinancialCalculationTarget.interestRate, '200'); + } + + Finder inBand(String text) => find.descendant( + of: find.byType(CalculatorBand), + matching: find.text(text), + ); + + group('FinancingPage', () { + testWidgets('monta faixa, lista de campos e barra de ações', ( + tester, + ) async { + await pump(tester); + + expect(find.byType(CalculatorBand), findsOneWidget); + expect(find.byType(FieldRow), findsNWidgets(4)); + expect(find.byType(BottomActionBar), findsOneWidget); + expect(find.text('Financiamento'), findsOneWidget); + for (final label in [ + 'Valor financiado', + 'Prazo', + 'Taxa de juros', + 'Prestação', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('meses'), findsOneWidget); + expect(find.text('% a.m.'), findsOneWidget); + expect(inBand('e descubra o quarto'), findsOneWidget); + expect(find.byType(ResultDetails), findsNothing); + expect(find.byType(FormNotice), findsNothing); }); - testWidgets('Deve garantir que a página inteira possui um scroll ativado', (tester) async { - await tester.pumpWidget(createSut()); + testWidgets('com três campos, a prestação fica "a descobrir"', ( + tester, + ) async { + await pump(tester); + await fillExample(tester); - expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(inBand('a prestação'), findsOneWidget); + expect( + find.descendant( + of: row(FinancialCalculationTarget.finalValue), + matching: find.text(FieldRow.calculateText), + ), + findsOneWidget, + ); }); - }); - group("FinancingPage - Testes da PageHeader", () { - testWidgets('Deve conseguir criar uma PageHeader', (tester) async { - await tester.pumpWidget(createSut()); - expect(find.byType(PageHeader), findsOneWidget); + + testWidgets('2.500 / 12 / 2%: prestação, total, juros, taxa anual e ' + 'barra 88/12', (tester) async { + await pump(tester); + await fillExample(tester); + + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + expect(inBand('Sua prestação vai ser'), findsOneWidget); + expect(inBand('236,40'), findsOneWidget); + expect(inBand('12 parcelas iguais, pela Tabela Price'), findsOneWidget); + expect(formCubit.finalValue.text, '236,40'); + + expect(find.text('Do total que você vai pagar'), findsOneWidget); + expect(find.textContaining('2.836,80'), findsOneWidget); + expect(find.textContaining('336,80'), findsOneWidget); + expect(find.text('26,82% a.a.'), findsOneWidget); + expect(find.text('Emprestado 88%'), findsOneWidget); + expect(find.text('Juros 12%'), findsOneWidget); + }); + + testWidgets('tocar na linha "a descobrir" também calcula', (tester) async { + await pump(tester); + await fillExample(tester); + + await tester.tap(find.text(FieldRow.calculateText)); + await tester.pumpAndSettle(); + + expect(inBand('236,40'), findsOneWidget); + }); + + testWidgets('calcula a taxa a partir da prestação', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.initialValue, '250000'); + await type(tester, FinancialCalculationTarget.periods, '12'); + await type(tester, FinancialCalculationTarget.finalValue, '23640'); + + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + expect(inBand('A taxa de juros é de'), findsOneWidget); + expect(inBand('2,00% ao mês'), findsOneWidget); + }); + + testWidgets('com menos de três campos, mostra o aviso com os campos ' + 'vazios e desabilita "Calcular"', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.initialValue, '250000'); + await type(tester, FinancialCalculationTarget.periods, '12'); + + expect(find.text('Faltam dados para calcular'), findsOneWidget); + expect( + find.text( + 'Preencha a taxa de juros ou a prestação. ' + 'O campo que ficar em branco é calculado.', + ), + findsOneWidget, + ); + expect(inBand('falta 1 campo'), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('limpar mostra "Campos limpos" e desfazer recalcula', ( + tester, + ) async { + await pump(tester); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel(CalculatorBand.clearLabel)); + await tester.pumpAndSettle(); + + expect(find.text('Campos limpos'), findsOneWidget); + expect(inBand('e descubra o quarto'), findsOneWidget); + expect(formCubit.initialValue.text, isEmpty); + + await tester.tap(find.text('Desfazer')); + await tester.pumpAndSettle(); + + expect(formCubit.initialValue.text, '2.500,00'); + expect(inBand('236,40'), findsOneWidget); + expect(find.byType(ResultDetails), findsOneWidget); + }); + + testWidgets('compartilhar fica desabilitado sem resultado e envia o ' + 'texto ancorado no botão depois de calcular', (tester) async { + await pump(tester); + await fillExample(tester); + + final share = find.byType(OutlinedButton); + expect(tester.widget(share).onPressed, isNull); + + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + await tester.tap(share); + + expect(sharer.shared, hasLength(1)); + final (text, origin) = sharer.shared.single; + expect(text, startsWith('RealCalc · Financiamento\n')); + expect(text, contains('Total pago: R\$ 2.836,80')); + expect(origin, tester.getRect(share)); + }); + + testWidgets('"?" abre a ajuda', (tester) async { + await pump(tester); + + await tester.tap(find.bySemanticsLabel(CalculatorBand.helpLabel)); + await tester.pumpAndSettle(); + + expect(find.text('Como funciona'), findsOneWidget); }); + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('em 360dp com fonte em 130% no tema ${themeMode.name}', ( + tester, + ) async { + await pump( + tester, + themeMode: themeMode, + size: const Size(360, 780), + textScale: 1.3, + ); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); - testWidgets("Deve conter uma fontSize de 18 e fontWeight bold", (tester) async { - await tester.pumpWidget(createSut()); + expect(tester.takeException(), isNull); + expect(inBand('236,40'), findsOneWidget); + }); + } - final textFinder = find.text('Financiamento'); - final RenderParagraph renderObject = tester.renderObject(textFinder.first); + testWidgets('o teclado não cobre o campo em foco', (tester) async { + await pump(tester, size: const Size(360, 700)); - final TextStyle? resolvedStyle = renderObject.text.style; + await tester.showKeyboard( + find.descendant( + of: row(FinancialCalculationTarget.finalValue), + matching: find.byType(TextField), + ), + ); + tester.view.viewInsets = const FakeViewPadding(bottom: 320); + await tester.pumpAndSettle(); - expect(resolvedStyle?.fontSize, 18); - expect(resolvedStyle?.fontWeight, FontWeight.w700); + final field = tester.getRect(row(FinancialCalculationTarget.finalValue)); + final bar = tester.getRect(find.byType(BottomActionBar)); + expect(bar.bottom, 700 - 320); + expect(field.bottom, lessThanOrEqualTo(bar.top)); }); }); } diff --git a/test/modules/financial_calculators/presentation/pages/future_value_page_test.dart b/test/modules/financial_calculators/presentation/pages/future_value_page_test.dart index ae7500c..a71f0d1 100644 --- a/test/modules/financial_calculators/presentation/pages/future_value_page_test.dart +++ b/test/modules/financial_calculators/presentation/pages/future_value_page_test.dart @@ -1,82 +1,217 @@ -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:real_calc/core/themes/app_theme.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/future_value/future_value_cubit.dart'; import 'package:real_calc/modules/financial_calculators/presentation/pages/future_value_page.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/widgets/forms_view.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; - -import '../../../../test_harness.dart'; +import 'package:real_calc/modules/financial_calculators/use_case/future_value/calculate_future_value.dart'; void main() { - late TestHarness harness; - + late FutureValueCubit futureValueCubit; + late FinancingFormCubit formCubit; + setUp(() { - harness = TestHarness()..setUpDefaults(); + futureValueCubit = FutureValueCubit(CalculateFutureValue()); + formCubit = FinancingFormCubit(); + }); + + tearDown(() async { + await futureValueCubit.close(); + await formCubit.close(); }); - - tearDown(() => harness.dispose()); - - Widget createSut() { - return MaterialApp( - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.dark, - home: MultiBlocProvider( - providers: [ - BlocProvider.value( - value: harness.futureValueCubit, - ), - BlocProvider.value( - value: harness.financingFormCubit, - ), - ], - child: const FutureValuePage(), + + Future pump( + WidgetTester tester, { + ThemeMode themeMode = ThemeMode.light, + Size size = const Size(400, 900), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: futureValueCubit), + BlocProvider.value(value: formCubit), + ], + child: const FutureValuePage(), + ), ), ); } - group('FutureValuePage - Testes de Integração da Tela', () { - testWidgets('Deve renderizar a árvore de componentes completa com sucesso', (tester) async { - await tester.pumpWidget(createSut()); + Finder row(FinancialCalculationTarget field) => find.byKey(ValueKey(field)); + + Future type( + WidgetTester tester, + FinancialCalculationTarget field, + String text, + ) async { + await tester.enterText( + find.descendant(of: row(field), matching: find.byType(TextField)), + text, + ); + await tester.pump(); + } + + Future fillExample(WidgetTester tester) async { + await type(tester, FinancialCalculationTarget.initialValue, '255555'); + await type(tester, FinancialCalculationTarget.periods, '25'); + await type(tester, FinancialCalculationTarget.interestRate, '033'); + } + + Finder inBand(String text) => find.descendant( + of: find.byType(CalculatorBand), + matching: find.text(text), + ); + + group('FutureValuePage', () { + testWidgets('monta a tela na cor do Valor Futuro', (tester) async { + await pump(tester); - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(TitleWidget), findsOneWidget); - expect(find.byType(HelpCard), findsOneWidget); - expect(find.byType(FormsView), findsOneWidget); + expect(find.text('Valor Futuro'), findsOneWidget); + expect(find.byType(FieldRow), findsNWidgets(4)); + for (final label in [ + 'Valor aplicado', + 'Prazo', + 'Taxa de juros', + 'Valor futuro', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(inBand('e descubra o quarto'), findsOneWidget); + + final band = tester.widget( + find + .descendant( + of: find.byType(CalculatorBand), + matching: find.byType(ColoredBox), + ) + .first, + ); + expect( + band.color, + CalculatorPalette.of(CalculatorKind.futureValue).solid, + ); }); - testWidgets('Deve garantir que a página inteira possui um scroll ativado', (tester) async { - await tester.pumpWidget(createSut()); + testWidgets('com três campos, pergunta pelo valor futuro', (tester) async { + await pump(tester); + await fillExample(tester); - expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(inBand('Você quer descobrir'), findsOneWidget); + expect(inBand('o valor futuro'), findsOneWidget); }); - }); - group("FutureValuePage - Testes da PageHeader", () { - testWidgets('Deve conseguir criar uma PageHeader', (tester) async { - await tester.pumpWidget(createSut()); - expect(find.byType(PageHeader), findsOneWidget); + testWidgets('2.555,55 / 25 / 0,33%: valor futuro, rendimento, taxa anual ' + 'e barra 92/8', (tester) async { + await pump(tester); + await fillExample(tester); + + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + expect(inBand('2.774,95'), findsOneWidget); + expect(formCubit.finalValue.text, '2.774,95'); + expect(find.text('Do valor futuro'), findsOneWidget); + expect(find.textContaining('219,40'), findsNWidgets(2)); + expect(find.text('4,03% a.a.'), findsOneWidget); + expect(find.text('Valor aplicado 92%'), findsOneWidget); + expect(find.text('Rendimento 8%'), findsOneWidget); + }); + + testWidgets('calcula o prazo a partir do valor futuro', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.initialValue, '255555'); + await type(tester, FinancialCalculationTarget.interestRate, '033'); + await type(tester, FinancialCalculationTarget.finalValue, '277495'); + + await tester.tap(find.text(FieldRow.calculateText)); + await tester.pumpAndSettle(); + + expect(inBand('25 meses'), findsOneWidget); + }); + + testWidgets('com menos de três campos, mostra o aviso', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.initialValue, '255555'); + await type(tester, FinancialCalculationTarget.periods, '25'); + + expect(find.text('Faltam dados para calcular'), findsOneWidget); + expect( + find.text( + 'Preencha a taxa de juros ou o valor futuro. ' + 'O campo que ficar em branco é calculado.', + ), + findsOneWidget, + ); + }); + + testWidgets('limpar e desfazer', (tester) async { + await pump(tester); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel(CalculatorBand.clearLabel)); + await tester.pumpAndSettle(); + expect(find.byType(ResultDetails), findsNothing); + + await tester.tap(find.text('Desfazer')); + await tester.pumpAndSettle(); + expect(inBand('2.774,95'), findsOneWidget); }); + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('em 360dp com fonte em 130% no tema ${themeMode.name}', ( + tester, + ) async { + await pump( + tester, + themeMode: themeMode, + size: const Size(360, 780), + textScale: 1.3, + ); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); - testWidgets("Deve conter uma fontSize de 18 e fontWeight bold", (tester) async { - await tester.pumpWidget(createSut()); + expect(tester.takeException(), isNull); + expect(inBand('2.774,95'), findsOneWidget); + }); + } - final textFinder = find.text('Valor Futuro'); - final RenderParagraph renderObject = tester.renderObject(textFinder.first); + testWidgets('o teclado não cobre o campo em foco', (tester) async { + await pump(tester, size: const Size(360, 700)); - final TextStyle? resolvedStyle = renderObject.text.style; + await tester.showKeyboard( + find.descendant( + of: row(FinancialCalculationTarget.finalValue), + matching: find.byType(TextField), + ), + ); + tester.view.viewInsets = const FakeViewPadding(bottom: 320); + await tester.pumpAndSettle(); - expect(resolvedStyle?.fontSize, 18); - expect(resolvedStyle?.fontWeight, FontWeight.w700); + final field = tester.getRect(row(FinancialCalculationTarget.finalValue)); + final bar = tester.getRect(find.byType(BottomActionBar)); + expect(bar.bottom, 700 - 320); + expect(field.bottom, lessThanOrEqualTo(bar.top)); }); }); } diff --git a/test/modules/financial_calculators/presentation/pages/regular_deposits_page_test.dart b/test/modules/financial_calculators/presentation/pages/regular_deposits_page_test.dart index 2e3740a..e06feff 100644 --- a/test/modules/financial_calculators/presentation/pages/regular_deposits_page_test.dart +++ b/test/modules/financial_calculators/presentation/pages/regular_deposits_page_test.dart @@ -2,65 +2,215 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:real_calc/core/themes/app_theme.dart'; -import 'package:real_calc/core/widgets/page_header.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/field_list/field_row.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/widgets/forms_view.dart'; -import 'package:real_calc/core/widgets/help_card.dart'; import 'package:real_calc/modules/financial_calculators/presentation/cubit/regular_deposits/regular_deposits_cubit.dart'; import 'package:real_calc/modules/financial_calculators/presentation/pages/regular_deposits_page.dart'; - -import '../../../../test_harness.dart'; +import 'package:real_calc/modules/financial_calculators/use_case/regular_deposits/calculate_deposit.dart'; void main() { - late TestHarness harness; - + late RegularDepositsCubit depositsCubit; + late FinancingFormCubit formCubit; + setUp(() { - harness = TestHarness()..setUpDefaults(); + depositsCubit = RegularDepositsCubit(CalculateDeposit()); + formCubit = FinancingFormCubit(); }); - - tearDown(() => harness.dispose()); - - Widget createSut() { - return MaterialApp( - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.dark, - home: MultiBlocProvider( - providers: [ - BlocProvider.value( - value: harness.regularDepositsCubit, - ), - BlocProvider.value( - value: harness.financingFormCubit, - ), - ], - child: const RegularDepositsPage(), + + tearDown(() async { + await depositsCubit.close(); + await formCubit.close(); + }); + + Future pump( + WidgetTester tester, { + ThemeMode themeMode = ThemeMode.light, + Size size = const Size(400, 900), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: depositsCubit), + BlocProvider.value(value: formCubit), + ], + child: const RegularDepositsPage(), + ), ), ); } - group('RegularDepositsPage - Testes de Integração da Tela', () { - testWidgets('Deve renderizar a árvore de componentes completa com sucesso', (tester) async { - await tester.pumpWidget(createSut()); + Finder row(FinancialCalculationTarget field) => find.byKey(ValueKey(field)); + + Future type( + WidgetTester tester, + FinancialCalculationTarget field, + String text, + ) async { + await tester.enterText( + find.descendant(of: row(field), matching: find.byType(TextField)), + text, + ); + await tester.pump(); + } + + Future fillExample(WidgetTester tester) async { + await type(tester, FinancialCalculationTarget.initialValue, '20000'); + await type(tester, FinancialCalculationTarget.periods, '24'); + await type(tester, FinancialCalculationTarget.interestRate, '080'); + } + + Finder inBand(String text) => find.descendant( + of: find.byType(CalculatorBand), + matching: find.text(text), + ); + + group('RegularDepositsPage', () { + testWidgets('monta a tela na cor dos Depósitos', (tester) async { + await pump(tester); + + expect(find.text('Depósitos Regulares'), findsOneWidget); + expect(find.byType(FieldRow), findsNWidgets(4)); + for (final label in [ + 'Depósito', + 'Prazo', + 'Taxa de juros', + 'Valor final', + ]) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('todo mês'), findsOneWidget); + expect(inBand('e descubra o quarto'), findsOneWidget); + + final band = tester.widget( + find + .descendant( + of: find.byType(CalculatorBand), + matching: find.byType(ColoredBox), + ) + .first, + ); + expect(band.color, CalculatorPalette.of(CalculatorKind.deposits).solid); + }); + + testWidgets('com três campos, pergunta pelo valor final', (tester) async { + await pump(tester); + await fillExample(tester); - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(TitleWidget), findsOneWidget); - expect(find.byType(HelpCard), findsOneWidget); - expect(find.byType(FormsView), findsOneWidget); + expect(inBand('Você quer descobrir'), findsOneWidget); + expect(inBand('o valor final'), findsOneWidget); + }); + + testWidgets('200 / 24 / 0,80%: valor final, depositado, rendimento, ' + 'taxa anual e barra 90/10', (tester) async { + await pump(tester); + await fillExample(tester); + + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + expect(inBand('5.310,78'), findsOneWidget); + expect(formCubit.finalValue.text, '5.310,78'); + expect(find.text('Do valor final'), findsOneWidget); + expect(find.textContaining('4.800,00'), findsOneWidget); + expect(find.textContaining('510,78'), findsNWidgets(2)); + expect(find.text('10,03% a.a.'), findsOneWidget); + expect(find.text('Depositado 90%'), findsOneWidget); + expect(find.text('Rendimento 10%'), findsOneWidget); + }); + + testWidgets('calcula o depósito a partir do valor final', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.periods, '24'); + await type(tester, FinancialCalculationTarget.interestRate, '080'); + await type(tester, FinancialCalculationTarget.finalValue, '531078'); + + await tester.tap(find.text(FieldRow.calculateText)); + await tester.pumpAndSettle(); + + expect(inBand('Para juntar R\$\u00A05.310,78, guarde por mês'), findsOne); + expect(inBand('200,00'), findsOneWidget); + }); - expect(find.text('Depósitos Regulares'), findsNWidgets(2)); + testWidgets('com menos de três campos, mostra o aviso', (tester) async { + await pump(tester); + await type(tester, FinancialCalculationTarget.initialValue, '20000'); + + expect(find.text('Faltam dados para calcular'), findsOneWidget); expect( - find.text('Calcule o valor futuro, a taxa, o prazo ou o depósito mensal.'), + find.text( + 'Preencha o prazo, a taxa de juros ou o valor final. ' + 'O campo que ficar em branco é calculado.', + ), findsOneWidget, ); }); - testWidgets('Deve garantir que a página inteira possui um scroll ativado', (tester) async { - await tester.pumpWidget(createSut()); + testWidgets('limpar e desfazer', (tester) async { + await pump(tester); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + await tester.tap(find.bySemanticsLabel(CalculatorBand.clearLabel)); + await tester.pumpAndSettle(); + expect(find.byType(ResultDetails), findsNothing); + + await tester.tap(find.text('Desfazer')); + await tester.pumpAndSettle(); + expect(inBand('5.310,78'), findsOneWidget); + }); + + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('em 360dp com fonte em 130% no tema ${themeMode.name}', ( + tester, + ) async { + await pump( + tester, + themeMode: themeMode, + size: const Size(360, 780), + textScale: 1.3, + ); + await fillExample(tester); + await tester.tap(find.text('Calcular')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(inBand('5.310,78'), findsOneWidget); + }); + } + + testWidgets('o teclado não cobre o campo em foco', (tester) async { + await pump(tester, size: const Size(360, 700)); + + await tester.showKeyboard( + find.descendant( + of: row(FinancialCalculationTarget.finalValue), + matching: find.byType(TextField), + ), + ); + tester.view.viewInsets = const FakeViewPadding(bottom: 320); + await tester.pumpAndSettle(); - expect(find.byType(SingleChildScrollView), findsOneWidget); + final field = tester.getRect(row(FinancialCalculationTarget.finalValue)); + final bar = tester.getRect(find.byType(BottomActionBar)); + expect(bar.bottom, 700 - 320); + expect(field.bottom, lessThanOrEqualTo(bar.top)); }); }); } diff --git a/test/modules/financial_calculators/presentation/utils/deposits_statement_test.dart b/test/modules/financial_calculators/presentation/utils/deposits_statement_test.dart new file mode 100644 index 0000000..0a6950b --- /dev/null +++ b/test/modules/financial_calculators/presentation/utils/deposits_statement_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/deposits_statement.dart'; + +void main() { + const deposit = FinancialCalculationTarget.initialValue; + const periods = FinancialCalculationTarget.periods; + const rate = FinancialCalculationTarget.interestRate; + const finalValue = FinancialCalculationTarget.finalValue; + + final solved = FinancialCalculation( + initialValue: 200, + periods: 24, + rate: 0.8, + finalValue: 5310.7801, + ); + final nbsp = String.fromCharCode(0xA0); + String money(String value) => 'R\$$nbsp$value'; + + BandStatement statement( + Set empty, { + FinancialCalculationTarget? calculated, + }) => depositsStatement( + emptyFields: empty, + values: calculated == null ? FinancialCalculation() : solved, + calculatedField: calculated, + ); + + group('depositsStatement', () { + test('valor final calculado', () { + expect( + statement({}, calculated: finalValue), + BandStatement( + kicker: 'Guardando ${money('200,00')} por mês, você terá', + value: '5.310,78', + isMoney: true, + sub: 'em 24 meses, com ${money('510,78')} de rendimento', + ), + ); + }); + + test('depósito calculado', () { + expect( + statement({}, calculated: deposit), + BandStatement( + kicker: 'Para juntar ${money('5.310,78')}, guarde por mês', + value: '200,00', + isMoney: true, + sub: 'durante 24 meses', + ), + ); + }); + + test('prazo calculado', () { + expect( + statement({}, calculated: periods), + BandStatement( + kicker: + 'Guardando ${money('200,00')} por mês, você junta ' + '${money('5.310,78')} em', + value: '24 meses', + sub: 'com ${money('510,78')} de rendimento', + ), + ); + }); + + test('taxa calculada', () { + expect( + statement({}, calculated: rate), + const BandStatement( + kicker: 'Para isso, o dinheiro precisa render', + value: '0,80% ao mês', + sub: '10,03% ao ano', + ), + ); + }); + + test('inicial, pergunta e aviso seguem o Financiamento', () { + expect( + statement({deposit, periods, rate, finalValue}).value, + 'e descubra o quarto', + ); + expect(statement({finalValue}).value, 'o valor final'); + expect(statement({deposit}).value, 'o depósito'); + expect(statement({rate, finalValue}).value, 'falta 1 campo'); + }); + + test('aviso cita os campos vazios', () { + expect( + depositsMissingFieldsMessage({deposit, finalValue}), + 'Preencha o depósito ou o valor final. ' + 'O campo que ficar em branco é calculado.', + ); + }); + }); +} diff --git a/test/modules/financial_calculators/presentation/utils/financing_statement_test.dart b/test/modules/financial_calculators/presentation/utils/financing_statement_test.dart new file mode 100644 index 0000000..e686c40 --- /dev/null +++ b/test/modules/financial_calculators/presentation/utils/financing_statement_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/financing_statement.dart'; + +void main() { + const initialValue = FinancialCalculationTarget.initialValue; + const periods = FinancialCalculationTarget.periods; + const rate = FinancialCalculationTarget.interestRate; + const installment = FinancialCalculationTarget.finalValue; + + final solved = FinancialCalculation( + initialValue: 2500, + periods: 12, + rate: 2, + finalValue: 236.3990, + ); + final nbsp = String.fromCharCode(0xA0); + + BandStatement statement( + Set empty, { + FinancialCalculationTarget? calculated, + }) => financingStatement( + emptyFields: empty, + values: calculated == null ? FinancialCalculation() : solved, + calculatedField: calculated, + ); + + group('financingStatement', () { + test('inicial', () { + expect( + statement({initialValue, periods, rate, installment}), + const BandStatement( + kicker: 'Informe três valores', + value: 'e descubra o quarto', + sub: 'Qualquer campo pode ser o resultado.', + ), + ); + }); + + test('falta a prestação', () { + expect( + statement({installment}), + const BandStatement( + kicker: 'Você quer descobrir', + value: 'a prestação', + sub: 'Os outros três campos já estão preenchidos.', + ), + ); + }); + + test('"Você quer descobrir" vale para os quatro campos', () { + expect(statement({initialValue}).value, 'o valor financiado'); + expect(statement({periods}).value, 'o prazo'); + expect(statement({rate}).value, 'a taxa de juros'); + }); + + test('prestação calculada', () { + expect( + statement({}, calculated: installment), + const BandStatement( + kicker: 'Sua prestação vai ser', + value: '236,40', + isMoney: true, + sub: '12 parcelas iguais, pela Tabela Price', + ), + ); + }); + + test('valor calculado', () { + expect( + statement({}, calculated: initialValue), + BandStatement( + kicker: 'Você pode financiar', + value: '2.500,00', + isMoney: true, + sub: 'em 12 parcelas de R\$${nbsp}236,40', + ), + ); + }); + + test('prazo calculado', () { + expect( + statement({}, calculated: periods), + BandStatement( + kicker: 'Você quita em', + value: '12 meses', + sub: 'com parcelas de R\$${nbsp}236,40', + ), + ); + }); + + test('taxa calculada', () { + expect( + statement({}, calculated: rate), + const BandStatement( + kicker: 'A taxa de juros é de', + value: '2,00% ao mês', + sub: '26,82% ao ano', + ), + ); + }); + + test('faltam campos: "falta 1" e "faltam 2"', () { + expect( + statement({rate, installment}), + const BandStatement( + kicker: 'Para calcular, ainda', + value: 'falta 1 campo', + sub: 'Deixe em branco só o que você quer descobrir.', + ), + ); + expect(statement({periods, rate, installment}).value, 'faltam 2 campos'); + }); + + test('com os quatro preenchidos e nada calculado', () { + expect(statement({}).value, '1 campo em branco'); + }); + }); + + group('financingMissingFieldsMessage', () { + test('cita os campos vazios na ordem da tela', () { + expect( + financingMissingFieldsMessage({installment, rate}), + 'Preencha a taxa de juros ou a prestação. ' + 'O campo que ficar em branco é calculado.', + ); + expect( + financingMissingFieldsMessage({installment, periods, rate}), + 'Preencha o prazo, a taxa de juros ou a prestação. ' + 'O campo que ficar em branco é calculado.', + ); + }); + }); +} diff --git a/test/modules/financial_calculators/presentation/utils/future_value_statement_test.dart b/test/modules/financial_calculators/presentation/utils/future_value_statement_test.dart new file mode 100644 index 0000000..68b1381 --- /dev/null +++ b/test/modules/financial_calculators/presentation/utils/future_value_statement_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/future_value_statement.dart'; + +void main() { + const applied = FinancialCalculationTarget.initialValue; + const periods = FinancialCalculationTarget.periods; + const rate = FinancialCalculationTarget.interestRate; + const future = FinancialCalculationTarget.finalValue; + + final solved = FinancialCalculation( + initialValue: 2555.55, + periods: 25, + rate: 0.33, + finalValue: 2774.947, + ); + final nbsp = String.fromCharCode(0xA0); + String money(String value) => 'R\$$nbsp$value'; + + BandStatement statement( + Set empty, { + FinancialCalculationTarget? calculated, + }) => futureValueStatement( + emptyFields: empty, + values: calculated == null ? FinancialCalculation() : solved, + calculatedField: calculated, + ); + + group('futureValueStatement', () { + test('valor futuro calculado', () { + expect( + statement({}, calculated: future), + BandStatement( + kicker: 'Aplicando ${money('2.555,55')} hoje, você terá', + value: '2.774,95', + isMoney: true, + sub: 'em 25 meses, com ${money('219,40')} de rendimento', + ), + ); + }); + + test('valor aplicado calculado', () { + expect( + statement({}, calculated: applied), + BandStatement( + kicker: 'Para ter ${money('2.774,95')}, aplique hoje', + value: '2.555,55', + isMoney: true, + sub: 'por 25 meses', + ), + ); + }); + + test('prazo calculado', () { + expect( + statement({}, calculated: periods), + BandStatement( + kicker: '${money('2.555,55')} viram ${money('2.774,95')} em', + value: '25 meses', + sub: 'com ${money('219,40')} de rendimento', + ), + ); + }); + + test('taxa calculada', () { + expect( + statement({}, calculated: rate), + const BandStatement( + kicker: 'Para isso, o dinheiro precisa render', + value: '0,33% ao mês', + sub: '4,03% ao ano', + ), + ); + }); + + test('inicial, pergunta e aviso seguem o Financiamento', () { + expect( + statement({applied, periods, rate, future}).value, + 'e descubra o quarto', + ); + expect(statement({future}).value, 'o valor futuro'); + expect(statement({applied}).value, 'o valor aplicado'); + expect(statement({periods, rate, future}).value, 'faltam 2 campos'); + }); + + test('aviso cita os campos vazios', () { + expect( + futureValueMissingFieldsMessage({rate, future}), + 'Preencha a taxa de juros ou o valor futuro. ' + 'O campo que ficar em branco é calculado.', + ); + }); + }); +} diff --git a/test/modules/financial_calculators/presentation/utils/result_summaries_test.dart b/test/modules/financial_calculators/presentation/utils/result_summaries_test.dart new file mode 100644 index 0000000..3868f0d --- /dev/null +++ b/test/modules/financial_calculators/presentation/utils/result_summaries_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/result_summaries.dart'; + +void main() { + group('financingResultSummary', () { + // R$ 2.500,00 a 2% a.m. em 12 meses: prestação de R$ 236,399 → 236,40. + final calculation = FinancialCalculation( + initialValue: 2500, + rate: 2, + periods: 12, + finalValue: 236.3990, + ); + + test('o total sai da prestação arredondada: 236,40 × 12', () { + final summary = financingResultSummary(calculation); + + expect(summary.title, 'Do total que você vai pagar'); + expect(summary.lines, const [ + ResultDetailLine('Total pago', 'R\$\u00A02.836,80'), + ResultDetailLine('Total de juros', 'R\$\u00A0336,80'), + ResultDetailLine('Taxa anual equivalente', '26,82% a.a.'), + ]); + }); + + test('composição: emprestado 88%, juros 12%', () { + final composition = financingResultSummary(calculation).composition!; + + expect(composition.own, const ResultSegment('Emprestado', 88)); + expect(composition.other, const ResultSegment('Juros', 12)); + expect(composition.semanticsLabel, 'Emprestado 88%, Juros 12%'); + }); + }); + + group('regularDepositsResultSummary', () { + test('R\$ 100,00 por mês a 1% em 12 meses', () { + final summary = regularDepositsResultSummary( + FinancialCalculation( + initialValue: 100, + rate: 1, + periods: 12, + finalValue: 1280.9328, + ), + ); + + expect(summary.title, 'Do valor final'); + expect(summary.lines, const [ + ResultDetailLine('Total depositado', 'R\$\u00A01.200,00'), + ResultDetailLine('Rendimento', 'R\$\u00A080,93'), + ResultDetailLine('Taxa anual equivalente', '12,68% a.a.'), + ]); + expect(summary.composition!.own, const ResultSegment('Depositado', 94)); + expect(summary.composition!.other, const ResultSegment('Rendimento', 6)); + }); + }); + + group('futureValueResultSummary', () { + test('R\$ 1.000,00 a 1% em 12 meses', () { + final summary = futureValueResultSummary( + FinancialCalculation( + initialValue: 1000, + rate: 1, + periods: 12, + finalValue: 1126.8250, + ), + ); + + expect(summary.lines, const [ + ResultDetailLine('Valor aplicado', 'R\$\u00A01.000,00'), + ResultDetailLine('Rendimento', 'R\$\u00A0126,83'), + ResultDetailLine('Taxa anual equivalente', '12,68% a.a.'), + ]); + expect( + summary.composition!.own, + const ResultSegment('Valor aplicado', 89), + ); + expect(summary.composition!.other, const ResultSegment('Rendimento', 11)); + }); + + test('taxa zero: rendimento 0% e a barra continua somando 100', () { + final composition = futureValueResultSummary( + FinancialCalculation( + initialValue: 1000, + rate: 0, + periods: 12, + finalValue: 1000, + ), + ).composition!; + + expect(composition.own.percent, 100); + expect(composition.other.percent, 0); + }); + }); + + group('ResultComposition', () { + test('os percentuais sempre somam 100', () { + for (final (own, other) in [ + (250000, 33680), + (1, 2), + (1, 1), + (333, 667), + (99950, 50), + (123456789, 98765), + ]) { + final composition = ResultComposition.fromCents( + ownLabel: 'a', + ownCents: own, + otherLabel: 'b', + otherCents: other, + )!; + expect( + composition.own.percent + composition.other.percent, + 100, + reason: '$own + $other', + ); + } + }); + + test('sem total positivo ou com parte negativa, não há barra', () { + ResultComposition? of(int own, int other) => ResultComposition.fromCents( + ownLabel: 'a', + ownCents: own, + otherLabel: 'b', + otherCents: other, + ); + + expect(of(0, 0), isNull); + expect(of(1000, -50), isNull); + expect(of(-10, 50), isNull); + }); + }); +} diff --git a/test/modules/financial_calculators/presentation/utils/share_texts_test.dart b/test/modules/financial_calculators/presentation/utils/share_texts_test.dart new file mode 100644 index 0000000..33caba4 --- /dev/null +++ b/test/modules/financial_calculators/presentation/utils/share_texts_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; +import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; +import 'package:real_calc/modules/financial_calculators/presentation/utils/share_texts.dart'; + +void main() { + group('financingShareText', () { + final values = FinancialCalculation( + initialValue: 2500, + periods: 12, + rate: 2, + finalValue: 236.3990, + ); + + test('prestação calculada, igual ao exemplo da issue', () { + expect( + financingShareText(values, FinancialCalculationTarget.finalValue), + 'RealCalc · Financiamento\n' + 'Sua prestação vai ser R\$ 236,40\n' + '12 parcelas iguais, pela Tabela Price\n' + '\n' + 'Valor financiado: R\$ 2.500,00\n' + 'Prazo: 12 meses\n' + 'Taxa de juros: 2,00% ao mês\n' + 'Total pago: R\$ 2.836,80\n' + 'Juros: R\$ 336,80\n' + '\n' + 'Simulação feita no RealCalc, sem valor oficial.', + ); + }); + + test('taxa calculada lista a prestação no lugar da taxa', () { + final text = financingShareText( + values, + FinancialCalculationTarget.interestRate, + ); + + expect(text, contains('A taxa de juros é de 2,00% ao mês\n')); + expect(text, contains('26,82% ao ano\n')); + expect(text, contains('Prestação: R\$ 236,40\n')); + expect(text, isNot(contains('Taxa de juros:'))); + }); + + test('não tem espaço não separável', () { + final text = financingShareText( + values, + FinancialCalculationTarget.initialValue, + ); + expect(text.contains(String.fromCharCode(0xA0)), isFalse); + expect(text, contains('Você pode financiar R\$ 2.500,00\n')); + expect(text, contains('em 12 parcelas de R\$ 236,40\n')); + }); + }); + + test('depositsShareText', () { + expect( + depositsShareText( + FinancialCalculation( + initialValue: 200, + periods: 24, + rate: 0.8, + finalValue: 5310.7801, + ), + FinancialCalculationTarget.finalValue, + ), + 'RealCalc · Depósitos Regulares\n' + 'Guardando R\$ 200,00 por mês, você terá R\$ 5.310,78\n' + 'em 24 meses, com R\$ 510,78 de rendimento\n' + '\n' + 'Depósito mensal: R\$ 200,00\n' + 'Prazo: 24 meses\n' + 'Taxa de juros: 0,80% ao mês\n' + 'Total depositado: R\$ 4.800,00\n' + 'Rendimento: R\$ 510,78\n' + '\n' + 'Simulação feita no RealCalc, sem valor oficial.', + ); + }); + + test('futureValueShareText', () { + expect( + futureValueShareText( + FinancialCalculation( + initialValue: 2555.55, + periods: 25, + rate: 0.33, + finalValue: 2774.947, + ), + FinancialCalculationTarget.finalValue, + ), + 'RealCalc · Valor Futuro\n' + 'Aplicando R\$ 2.555,55 hoje, você terá R\$ 2.774,95\n' + 'em 25 meses, com R\$ 219,40 de rendimento\n' + '\n' + 'Valor aplicado: R\$ 2.555,55\n' + 'Prazo: 25 meses\n' + 'Taxa de juros: 0,33% ao mês\n' + 'Rendimento: R\$ 219,40\n' + '\n' + 'Simulação feita no RealCalc, sem valor oficial.', + ); + }); +} diff --git a/test/modules/financial_calculators/presentation/widgets/forms_view_test.dart b/test/modules/financial_calculators/presentation/widgets/forms_view_test.dart deleted file mode 100644 index cb6252b..0000000 --- a/test/modules/financial_calculators/presentation/widgets/forms_view_test.dart +++ /dev/null @@ -1,535 +0,0 @@ -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/themes/extensions/financing_forms_theme.dart'; -import 'package:real_calc/core/themes/extensions/input_forms_result_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/options_bottom_forms_theme.dart'; -import 'package:real_calc/core/themes/text_styles.dart'; -import 'package:real_calc/core/utils/decimal_input_formatter.dart'; -import 'package:real_calc/core/widgets/options_bottom_forms.dart'; -import 'package:real_calc/modules/financial_calculators/domain/entities/financial_calculation.dart'; -import 'package:real_calc/modules/financial_calculators/domain/enum/financial_calculation_target.dart'; -import 'package:real_calc/core/widgets/types/input_field_state.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/cubit/financing/financing_cubit.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/cubit/forms/financing_form_cubit.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/cubit/forms/financing_form_state.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/models/field_spec.dart'; -import 'package:real_calc/modules/financial_calculators/presentation/widgets/forms_view.dart'; -import 'package:real_calc/core/widgets/input_forms_result_card.dart'; - -import '../../../../mock_cubits.dart'; -import '../../../../test_harness.dart'; - -enum _TestField { valor, taxa, prazo } - -void main() { - final resultCardTheme = InputFormsResultCardTheme( - neutralIconColor: Colors.grey, - neutralBorderColor: Colors.grey.shade300, - neutralLabelColor: Colors.black54, - highlightedColor: Colors.orange, - calculatedColor: Colors.blue, - errorColor: Colors.red, - borderRadius: 12, - badgeStyle: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), - ); - - const optionsBottomFormsTheme = OptionsBottomFormsTheme( - calculateButtonBackground: Colors.orange, - calculateButtonForeground: Colors.white, - actionButtonBackground: Colors.grey, - actionButtonForeground: Colors.black87, - borderButtonColor: ColorTokens.successContainerBorder, - ); - - final financingFormsTheme = FinancingFormsTheme( - progressIndicatorColor: Colors.blue, - errorContainerPadding: const EdgeInsets.all(12), - errorBackgroundColor: Colors.red.shade50, - errorBorderColor: Colors.red, - iconColor: Colors.white, - errorTextStyle: const TextStyle(color: Colors.red, fontSize: 13), - ); - - final sharedThemeExtensions = ThemeData( - extensions: [resultCardTheme, optionsBottomFormsTheme, financingFormsTheme], - ); - - group('FormsView - Comportamento genérico (widget isolado)', () { - late Map<_TestField, TextEditingController> controllers; - late List> fieldSpecs; - - setUp(() { - controllers = { - _TestField.valor: TextEditingController(), - _TestField.taxa: TextEditingController(), - _TestField.prazo: TextEditingController(), - }; - - fieldSpecs = [ - FieldSpec<_TestField>( - field: _TestField.valor, - label: 'Valor do imóvel', - hint: 'R\$ 0,00', - icon: Icons.home, - formatters: const [], - ), - FieldSpec<_TestField>( - field: _TestField.taxa, - label: 'Taxa de juros', - hint: '0,00%', - icon: Icons.percent, - formatters: [FilteringTextInputFormatter.digitsOnly], - ), - FieldSpec<_TestField>( - field: _TestField.prazo, - label: 'Prazo', - hint: '0 meses', - icon: Icons.calendar_month, - formatters: const [], - ), - ]; - }); - - tearDown(() { - for (final controller in controllers.values) { - controller.dispose(); - } - }); - - Widget buildGenericFormsView({ - bool isLoading = false, - String? errorMessage, - VoidCallback? onCalculate, - VoidCallback? onClear, - ValueChanged<_TestField>? onFieldTap, - InputFieldState Function(_TestField field)? stateOf, - }) { - return MaterialApp( - theme: sharedThemeExtensions, - home: Scaffold( - body: SingleChildScrollView( - child: FormsView<_TestField>( - fieldSpecs: fieldSpecs, - controllerFor: (field) => controllers[field]!, - stateOf: stateOf ?? (_) => InputFieldState.neutral, - isLoading: isLoading, - errorMessage: errorMessage, - onCalculate: onCalculate, - onClear: onClear, - onFieldTap: onFieldTap, - ), - ), - ), - ); - } - - testWidgets('renderiza um InputFormsResultCard para cada FieldSpec', - (tester) async { - await tester.pumpWidget(buildGenericFormsView()); - - expect(find.byType(InputFormsResultCard), findsNWidgets(3)); - expect(find.text('Valor do imóvel'), findsOneWidget); - expect(find.text('Taxa de juros'), findsOneWidget); - expect(find.text('Prazo'), findsOneWidget); - }); - - testWidgets('exibe o banner de erro quando errorMessage não é nulo', - (tester) async { - await tester.pumpWidget( - buildGenericFormsView(errorMessage: 'Preencha todos os campos'), - ); - - expect(find.text('Preencha todos os campos'), findsOneWidget); - expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); - }); - - testWidgets('não exibe o banner de erro quando errorMessage é nulo', - (tester) async { - await tester.pumpWidget(buildGenericFormsView()); - - expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); - }); - - testWidgets('exibe indicador de carregamento quando isLoading é true', - (tester) async { - await tester.pumpWidget(buildGenericFormsView(isLoading: true)); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - }); - - testWidgets( - 'não exibe indicador de carregamento quando isLoading é false', - (tester) async { - await tester.pumpWidget(buildGenericFormsView()); - - expect(find.byType(LinearProgressIndicator), findsNothing); - }); - - testWidgets('chama onFieldTap com o campo correto ao tocar no card', - (tester) async { - _TestField? tappedField; - - await tester.pumpWidget(buildGenericFormsView( - onFieldTap: (field) => tappedField = field, - )); - - await tester.tap(find.byType(TextField).at(1)); - await tester.pump(); - - expect(tappedField, _TestField.taxa); - }); - - testWidgets('usa o controller correto para cada campo', (tester) async { - await tester.pumpWidget(buildGenericFormsView()); - - await tester.enterText(find.byType(TextField).at(0), '500000'); - await tester.pump(); - - expect(controllers[_TestField.valor]!.text, '500000'); - expect(controllers[_TestField.taxa]!.text, isEmpty); - expect(controllers[_TestField.prazo]!.text, isEmpty); - }); - - testWidgets('usa o estado correto para cada campo', (tester) async { - await tester.pumpWidget(buildGenericFormsView( - stateOf: (field) => field == _TestField.taxa - ? InputFieldState.error - : InputFieldState.neutral, - )); - - final taxaIcon = tester.widget(find.byIcon(Icons.percent)); - expect(taxaIcon.color, resultCardTheme.errorColor); - - final valorIcon = tester.widget(find.byIcon(Icons.home)); - expect(valorIcon.color, resultCardTheme.neutralIconColor); - }); - - testWidgets('passa onCalculate e onClear para o OptionsBottomForms', - (tester) async { - void calculate() {} - void clear() {} - - await tester.pumpWidget(buildGenericFormsView( - onCalculate: calculate, - onClear: clear, - )); - - final optionsWidget = tester.widget( - find.byType(OptionsBottomForms), - ); - - expect(optionsWidget.onCalculate, calculate); - expect(optionsWidget.onClear, clear); - }); - - testWidgets('respeita os inputFormatters definidos no FieldSpec', - (tester) async { - await tester.pumpWidget(buildGenericFormsView()); - - await tester.enterText(find.byType(TextField).at(1), 'a1b2c3'); - await tester.pump(); - - expect(controllers[_TestField.taxa]!.text, '123'); - }); - }); - - group('FormsView - Integração com FinancingFormCubit real', () { - late TestHarness harness; - - final createdFormCubits = []; - - setUp(() { - harness = TestHarness()..setUpDefaults(); - }); - - tearDown(() async { - harness.dispose(); - for (final formCubit in createdFormCubits) { - await formCubit.close(); - } - createdFormCubits.clear(); - }); - - Widget createFinancingFormsSut({FinancingCubit? cubit}) { - final formCubit = FinancingFormCubit(); - createdFormCubits.add(formCubit); - final activeCubit = cubit ?? harness.financingCubit; - - final fieldSpecs = [ - FieldSpec( - field: FinancialCalculationTarget.initialValue, - label: 'Valor financiado (R\$)', - hint: '0,00', - icon: Icons.attach_money, - formatters: [DecimalInputFormatter()], - ), - FieldSpec( - field: FinancialCalculationTarget.periods, - label: 'Prazo (meses)', - hint: '0', - icon: Icons.calendar_today, - formatters: [FilteringTextInputFormatter.digitsOnly], - ), - FieldSpec( - field: FinancialCalculationTarget.interestRate, - label: 'Taxa de juros (% ao mês)', - hint: '0,00', - icon: Icons.percent, - formatters: [DecimalInputFormatter()], - ), - FieldSpec( - field: FinancialCalculationTarget.finalValue, - label: 'Valor da prestação', - hint: '0,00', - icon: Icons.payments, - formatters: [DecimalInputFormatter()], - ), - ]; - - return MaterialApp( - theme: ThemeData( - extensions: [ - InputFormsResultCardTheme( - neutralBorderColor: ColorTokens.border, - neutralLabelColor: ColorTokens.textSecondary, - neutralIconColor: ColorTokens.textSecondary, - highlightedColor: ColorTokens.accentAmber, - calculatedColor: ColorTokens.success, - errorColor: ColorTokens.error, - badgeStyle: AppTextStyles.stateBadge, - ), - const OptionsBottomFormsTheme( - calculateButtonBackground: ColorTokens.accentAmber, - calculateButtonForeground: Colors.white, - actionButtonBackground: ColorTokens.surface, - actionButtonForeground: ColorTokens.textSecondary, - borderButtonColor: ColorTokens.successContainerBorder, - ), - FinancingFormsTheme( - iconColor: ColorTokens.accentAmber, - progressIndicatorColor: ColorTokens.accentAmber, - errorBackgroundColor: ColorTokens.errorContainerBg, - errorBorderColor: ColorTokens.errorContainerBorder, - errorTextStyle: AppTextStyles.errorBannerText, - ), - ], - ), - home: Scaffold( - body: MultiBlocProvider( - providers: [ - BlocProvider.value(value: activeCubit), - BlocProvider.value(value: formCubit), - ], - child: SingleChildScrollView( - child: BlocListener( - listener: (context, state) { - if (state is FinancingCalculated) { - formCubit.applyResult(state.value, state.calculatedField); - } else if (state is FinancingError) { - formCubit.invalidateCalculatedField(); - } - }, - child: BlocBuilder( - builder: (context, financingState) { - return BlocBuilder( - bloc: formCubit, - builder: (context, formState) { - void calculate() { - if (formState.canCalculate) { - activeCubit.calculate(formCubit.financing); - } - } - - return FormsView( - fieldSpecs: fieldSpecs, - controllerFor: formCubit.controllerFor, - stateOf: formState.stateOf, - isLoading: financingState is FinancingLoading, - errorMessage: financingState is FinancingError - ? financingState.message - : null, - onCalculate: calculate, - onClear: formCubit.clear, - onFieldTap: (field) { - if (formState.stateOf(field) == - InputFieldState.highlighted) { - calculate(); - } - }, - ); - }, - ); - }, - ), - ), - ), - ), - ), - ); - } - - testWidgets( - 'Deve exibir a barra de progresso quando o estado for FinancingLoading', - (tester) async { - when(() => harness.financingCubit.state) - .thenReturn(FinancingLoading()); - - await tester.pumpWidget(createFinancingFormsSut()); - - expect(find.byType(LinearProgressIndicator), findsOneWidget); - }, - ); - - testWidgets( - 'Deve exibir o banner com a mensagem de erro quando o estado for FinancingError', - (tester) async { - final whenListenCubit = MockFinancingCubit(); - - when(() => whenListenCubit.state).thenReturn(FinancingInitial()); - - const errorMessage = - 'Os seguintes campos não podem ficar vazios: Prazo.'; - - whenListen( - whenListenCubit, - Stream.fromIterable([FinancingError(errorMessage)]), - ); - - await tester.pumpWidget( - createFinancingFormsSut(cubit: whenListenCubit), - ); - await tester.pump(); - - expect( - find.textContaining('Os seguintes campos não podem ficar vazios'), - findsOneWidget, - ); - }, - ); - - testWidgets( - 'Deve preencher os inputs de texto automaticamente quando emitir FinancingCalculated', - (tester) async { - final whenListenCubit = MockFinancingCubit(); - - when(() => whenListenCubit.state).thenReturn(FinancingInitial()); - - whenListen( - whenListenCubit, - Stream.fromIterable([ - FinancingCalculated( - FinancialCalculation( - initialValue: 5000.0, - periods: 24, - rate: 1.99, - finalValue: 350.0, - ), - FinancialCalculationTarget.finalValue, - ), - ]), - ); - - await tester.pumpWidget( - createFinancingFormsSut(cubit: whenListenCubit), - ); - await tester.pump(); - - final inputs = find.byType(TextField); - - final TextField inputValor = tester.widget(inputs.at(0)); - final TextField inputPrazo = tester.widget(inputs.at(1)); - final TextField inputTaxa = tester.widget(inputs.at(2)); - final TextField inputPresta = tester.widget(inputs.at(3)); - - expect(inputValor.controller?.text, '5.000,00'); - expect(inputPrazo.controller?.text, '24'); - expect(inputTaxa.controller?.text, '1,99'); - expect(inputPresta.controller?.text, '350,00'); - }, - ); - - testWidgets( - 'Deve disparar o calculate do Cubit ao clicar no botão Calcular', - (tester) async { - when(() => harness.financingCubit.calculate(any())) - .thenAnswer((_) async {}); - - await tester.pumpWidget(createFinancingFormsSut()); - - final inputs = find.byType(TextField); - - await tester.enterText(inputs.at(0), '15000'); - await tester.enterText(inputs.at(1), '12'); - await tester.enterText(inputs.at(2), '199'); - await tester.pump(); - - await tester.tap(find.text('Calcular')); - await tester.pump(); - - verify( - () => harness.financingCubit.calculate( - any( - that: isA() - .having((f) => f.initialValue, 'initialValue', 150.00) - .having((f) => f.periods, 'months', 12) - .having((f) => f.rate, 'rate', 1.99), - ), - ), - ).called(1); - }, - ); - - testWidgets( - 'Deve limpar todos os campos de texto ao clicar no botão Limpar', - (tester) async { - await tester.pumpWidget(createFinancingFormsSut()); - - final inputs = find.byType(TextField); - - await tester.enterText(inputs.at(0), '2500'); - await tester.pump(); - - expect( - tester.widget(inputs.at(0)).controller?.text, - '25,00', - ); - - final clearButtonFinder = find.text('Limpar'); - - await tester.ensureVisible(clearButtonFinder); - await tester.pumpAndSettle(); - await tester.tap(clearButtonFinder); - await tester.pump(); - - expect(tester.widget(inputs.at(0)).controller?.text, ''); - expect(tester.widget(inputs.at(1)).controller?.text, ''); - }, - ); - - testWidgets( - 'Deve aplicar os inputFormatters e formatar o texto em tempo real durante a digitação', - (tester) async { - await tester.pumpWidget(createFinancingFormsSut()); - - final inputs = find.byType(TextField); - - await tester.enterText(inputs.at(0), '123456'); - await tester.pump(); - - await tester.enterText(inputs.at(1), '12abc3'); - await tester.pump(); - - final TextField inputValor = tester.widget(inputs.at(0)); - final TextField inputPrazo = tester.widget(inputs.at(1)); - - expect(inputValor.controller?.text, '1.234,56'); - expect(inputPrazo.controller?.text, '123'); - }, - ); - }); -} \ No newline at end of file diff --git a/test/modules/home/module_test.dart b/test/modules/home/module_test.dart index e19b862..ac64683 100644 --- a/test/modules/home/module_test.dart +++ b/test/modules/home/module_test.dart @@ -4,10 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:real_calc/core/themes/app_theme.dart'; import 'package:real_calc/modules/home/module.dart'; import 'package:real_calc/modules/home/presentation/page/page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { group('HomeModule Tests', () { setUp(() { + SharedPreferences.setMockInitialValues({}); Modular.init(HomeModule()); }); diff --git a/test/modules/home/presentation/components/highlight_card_test.dart b/test/modules/home/presentation/components/highlight_card_test.dart deleted file mode 100644 index 7505ccc..0000000 --- a/test/modules/home/presentation/components/highlight_card_test.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/themes/extensions/highlight_card_theme.dart'; -import 'package:real_calc/core/themes/text_styles.dart'; -import 'package:real_calc/modules/home/presentation/components/highlight_card.dart'; - -void main() { - group('HighlightCard Widget Tests', () { - Widget buildTestableWidget({VoidCallback? onTap}) { - return MaterialApp( - theme: ThemeData( - extensions: [ - HighlightCardTheme( - gradientColors: [ - ColorTokens.surface, - ColorTokens.accentAmber.withValues(alpha: 0.32), - ], - titleStyle: AppTextStyles.menuCardTitleFeatured, - subtitleStyle: AppTextStyles.menuCardDescriptionFeatured, - buttonBackgroundColor: Colors.white, - buttonForegroundColor: Colors.white, - buttonTextStyle: AppTextStyles.menuCardDescriptionFeatured, - ), - ], - ), - home: Scaffold( - body: HighlightCard(onTap: onTap), - ), - ); - } - - testWidgets('Deve renderizar todos os textos corretamente', (tester) async { - await tester.pumpWidget(buildTestableWidget()); - - expect(find.text('Selic e Índices'), findsOneWidget); - expect( - find.text('Acompanhe as taxas oficiais atualizadas diariamente pelo Banco Central.'), - findsOneWidget, - ); - expect(find.text('CONSULTAR TAXAS'), findsOneWidget); - }); - - testWidgets('Deve conter as decorações visuais corretas (Container e Gradiente)', (tester) async { - await tester.pumpWidget(buildTestableWidget()); - - final containerFinder = find.byType(Container); - final Container containerWidget = tester.widget(containerFinder); - final BoxDecoration decoration = containerWidget.decoration as BoxDecoration; - - expect(decoration.borderRadius, BorderRadius.circular(20)); - - final LinearGradient gradient = decoration.gradient as LinearGradient; - - expect(gradient.colors.length, greaterThanOrEqualTo(2)); - expect(gradient.colors.first, ColorTokens.surface); - expect(gradient.colors.last, ColorTokens.accentAmber.withValues(alpha: 0.32)); - expect(gradient.begin, Alignment.topLeft); - expect(gradient.end, Alignment.bottomRight); - }); - - testWidgets('Deve aplicar a tipografia e cores corretas aos textos', (tester) async { - await tester.pumpWidget(buildTestableWidget()); - - final titleText = tester.widget(find.text('Selic e Índices')); - expect(titleText.style, AppTextStyles.menuCardTitleFeatured); - - final descText = tester.widget( - find.text('Acompanhe as taxas oficiais atualizadas diariamente pelo Banco Central.'), - ); - expect(descText.style?.fontSize, AppTextStyles.menuCardDescriptionFeatured.fontSize); - expect(descText.style?.fontWeight, AppTextStyles.menuCardDescriptionFeatured.fontWeight); - expect( - descText.style?.color, - AppTextStyles.menuCardDescriptionFeatured.color?.withValues(alpha: 0.8), - ); - }); - - testWidgets('Deve conter o botão com o estilo correto e permitir clique', (tester) async { - bool wasTapped = false; - await tester.pumpWidget(buildTestableWidget(onTap: () => wasTapped = true)); - - final buttonFinder = find.byType(ElevatedButton); - expect(buttonFinder, findsOneWidget); - - final ElevatedButton buttonWidget = tester.widget(buttonFinder); - final ButtonStyle? buttonStyle = buttonWidget.style; - - expect( - buttonStyle?.backgroundColor?.resolve({}), - Colors.white.withValues(alpha: 0.2), - ); - expect( - buttonStyle?.foregroundColor?.resolve({}), - Colors.white, - ); - expect(buttonStyle?.elevation?.resolve({}), 0); - - await tester.tap(buttonFinder); - await tester.pump(); - - expect(wasTapped, true); - }); - }); -} \ No newline at end of file diff --git a/test/modules/home/presentation/components/menu_card_test.dart b/test/modules/home/presentation/components/menu_card_test.dart deleted file mode 100644 index 92d8472..0000000 --- a/test/modules/home/presentation/components/menu_card_test.dart +++ /dev/null @@ -1,223 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/menu_card_theme.dart'; -import 'package:real_calc/modules/home/domain/enum/menu_card_variant.dart'; -import 'package:real_calc/modules/home/presentation/components/menu_card.dart'; - -void main() { - const cardColor = Color(0xFF16222F); - const iconColor = Color(0xFFD4A657); - const title = 'Financiamento'; - const description = 'Prestações fixas com juros compostos'; - - // Valores explícitos usados tanto na construção do tema quanto nas - // asserções — se o widget mudar esses números no futuro, o teste - // aponta exatamente qual constante divergiu, em vez de um número cru. - const double borderRadius = 16; - const double heightThreshold = 100; - const double normalIconSize = 24; - const double compactIconSize = 20; - const double compactTitleFontSize = 11; - const double compactDescriptionFontSize = 9; - const featuredBorderColor = Color(0xFFD4A657); - const disabledLabelColor = Color(0xFF6C7480); - const titleStyle = TextStyle(fontSize: 14, fontWeight: FontWeight.w500); - const descriptionStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.w600); - const titleStyleCompact = TextStyle(fontSize: 11, fontWeight: FontWeight.w500); - const descriptionStyleCompact = TextStyle(fontSize: 9, fontWeight: FontWeight.w600); - const titleStyleFeatured = TextStyle(fontSize: 14, fontWeight: FontWeight.w500); - const descriptionStyleFeatured = TextStyle(fontSize: 16, fontWeight: FontWeight.w600); - const disabledTextStyle = TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: disabledLabelColor); - - - MenuCardTheme buildTheme() { - return const MenuCardTheme( - titleStyle: titleStyle, - descriptionStyle: descriptionStyle, - titleStyleCompact: titleStyleCompact, - descriptionStyleCompact: descriptionStyleCompact, - titleStyleFeatured: titleStyleFeatured, - descriptionStyleFeatured: descriptionStyleFeatured, - featuredBorderColor: featuredBorderColor, - disabledLabelColor: disabledLabelColor, - disabledTextStyle: disabledTextStyle, - borderRadius: borderRadius, - ); - } - - Widget buildTestableWidget({ - required MenuCardVariant variant, - double height = 150, - double width = 150, - VoidCallback? onTap, - }) { - return MaterialApp( - theme: ThemeData(extensions: [buildTheme()]), - home: Scaffold( - body: SizedBox( - height: height, - width: width, - child: MenuCard( - icon: Icons.account_balance, - cardColor: cardColor, - iconColor: iconColor, - title: title, - description: description, - onTap: onTap, - variant: variant, - ), - ), - ), - ); - } - - group('MenuCard — variante standard', () { - testWidgets('renderiza textos, ícone e cor de fundo corretamente', - (tester) async { - await tester.pumpWidget( - buildTestableWidget(variant: MenuCardVariant.standard), - ); - - expect(find.text(title), findsOneWidget); - expect(find.text(description), findsOneWidget); - - final iconWidget = tester.widget(find.byType(Icon)); - expect(iconWidget.icon, Icons.account_balance); - expect(iconWidget.color, iconColor); - expect(iconWidget.size, normalIconSize); - - final container = tester.widget( - find.byKey(const Key('menu_card_container')), - ); - final decoration = container.decoration as BoxDecoration; - expect(decoration.color, cardColor); - }); - - testWidgets( - 'adapta fontes e ícone quando a altura é menor que heightThreshold', - (tester) async { - await tester.pumpWidget( - buildTestableWidget( - variant: MenuCardVariant.standard, - height: heightThreshold - 20, - width: 120, - ), - ); - - final iconWidget = tester.widget(find.byType(Icon)); - expect(iconWidget.size, compactIconSize); - - final titleText = tester.widget(find.text(title)); - expect(titleText.style?.fontSize, compactTitleFontSize); - - final descText = tester.widget(find.text(description)); - expect(descText.style?.fontSize, compactDescriptionFontSize); - }); - - testWidgets('não usa o layout compacto quando a altura é igual ao threshold', - (tester) async { - // Fronteira exata: define se a comparação no widget é `<` ou `<=`. - await tester.pumpWidget( - buildTestableWidget( - variant: MenuCardVariant.standard, - height: heightThreshold, - width: 120, - ), - ); - - final iconWidget = tester.widget(find.byType(Icon)); - expect(iconWidget.size, normalIconSize); - }); - - testWidgets('não sofre overflow em tamanhos pequenos', (tester) async { - await tester.pumpWidget( - buildTestableWidget( - variant: MenuCardVariant.standard, - height: heightThreshold - 30, - width: 90, - ), - ); - - expect(tester.takeException(), isNull); - }); - - testWidgets('dispara onTap ao ser tocado', (tester) async { - var foiClicado = false; - - await tester.pumpWidget( - buildTestableWidget( - variant: MenuCardVariant.standard, - onTap: () => foiClicado = true, - ), - ); - - await tester.tap(find.byType(InkWell)); - await tester.pump(); - - expect(foiClicado, isTrue); - }); - }); - - group('MenuCard — variante featured', () { - testWidgets('renderiza em layout horizontal com borda de destaque', - (tester) async { - await tester.pumpWidget( - buildTestableWidget(variant: MenuCardVariant.featured, height: 90), - ); - - expect(find.text(title), findsOneWidget); - expect(find.text(description), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); - - final container = tester.widget( - find.byKey(const Key('menu_card_container')), - ); - final decoration = container.decoration as BoxDecoration; - expect(decoration.border, isA()); - final border = decoration.border as Border; - expect(border.left.color, featuredBorderColor); - }); - - testWidgets('dispara onTap ao ser tocado', (tester) async { - var foiClicado = false; - - await tester.pumpWidget( - buildTestableWidget( - variant: MenuCardVariant.featured, - height: 90, - onTap: () => foiClicado = true, - ), - ); - - await tester.tap(find.byType(InkWell)); - await tester.pump(); - - expect(foiClicado, isTrue); - }); - }); - - group('MenuCard — variante disabled', () { - testWidgets('exibe "em breve" e não possui InkWell/onTap', - (tester) async { - await tester.pumpWidget( - buildTestableWidget(variant: MenuCardVariant.disabled, height: 70), - ); - - expect(find.textContaining('em breve'), findsOneWidget); - expect(find.byType(InkWell), findsNothing); - }); - - testWidgets('não dispara nenhuma ação ao ser tocado', (tester) async { - // Não há callback pra checar (a variante disabled nem aceita onTap), - // então a asserção é a ausência de exceção e de área tocável. - await tester.pumpWidget( - buildTestableWidget(variant: MenuCardVariant.disabled, height: 70), - ); - - await tester.tap(find.byKey(const Key('menu_card_container'))); - await tester.pump(); - - expect(tester.takeException(), isNull); - }); - }); -} \ No newline at end of file diff --git a/test/modules/home/presentation/components/metric_card_test.dart b/test/modules/home/presentation/components/metric_card_test.dart deleted file mode 100644 index b650847..0000000 --- a/test/modules/home/presentation/components/metric_card_test.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:real_calc/core/themes/extensions/metric_card_theme.dart'; -import 'package:real_calc/modules/home/presentation/components/metric_card.dart'; - -void main() { - const positiveColor = Color(0xFF6FCF97); - const negativeColor = Color(0xFFE24B4A); - const neutralColor = Color(0xFF8B929C); - const skeletonColor = Color(0xFF12161D); - - MetricCardTheme buildTheme() { - return const MetricCardTheme( - backgroundColor: Color(0xFF0E1116), - borderColor: Color(0x33D4A657), - labelStyle: TextStyle(fontSize: 12), - valueStyle: TextStyle(fontSize: 30, fontWeight: FontWeight.w500), - valueUnitStyle: TextStyle(fontSize: 15), - captionStyle: TextStyle(fontSize: 11), - variationTextStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - variationPositiveColor: positiveColor, - variationNegativeColor: negativeColor, - variationNeutralColor: neutralColor, - skeletonColor: skeletonColor, - ); - } - - Widget wrap(Widget card) { - return MaterialApp( - theme: ThemeData(extensions: [buildTheme()]), - home: Scaffold(body: card), - ); - } - - group('MetricCard — estado data', () { - testWidgets('renderiza valor formatado, variação e caption', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.data( - ratePercent: 10.75, - variationPercent: 0.25, - sparklineData: [10.2, 10.4, 10.3, 10.6, 10.75], - caption: 'Atualizada pelo Banco Central hoje', - ))); - - expect(find.textContaining('10,75'), findsOneWidget); - expect(find.text('+0,25%'), findsOneWidget); - expect( - find.text('Atualizada pelo Banco Central hoje'), - findsOneWidget, - ); - }); - - testWidgets('usa a cor negativa quando a variação é menor que zero', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.data( - ratePercent: 10.5, - variationPercent: -0.5, - sparklineData: [10.9, 10.8, 10.6, 10.5], - caption: 'Atualizada pelo Banco Central hoje', - ))); - - final badge = tester.widget( - find.byKey(const Key('metric_card_variation_badge')), - ); - final decoration = badge.decoration as BoxDecoration; - expect(decoration.color, negativeColor.withValues(alpha: 0.12)); - }); - - testWidgets('dispara onTap quando fornecido', (tester) async { - var foiClicado = false; - - await tester.pumpWidget(wrap(MetricCard.data( - ratePercent: 10.75, - variationPercent: 0.25, - sparklineData: const [10.2, 10.4, 10.6, 10.75], - caption: 'Atualizada pelo Banco Central hoje', - onTap: () => foiClicado = true, - ))); - - await tester.tap(find.byKey(const Key('metric_card_container'))); - await tester.pump(); - - expect(foiClicado, isTrue); - }); - - testWidgets('não quebra quando a sparkline tem menos de 2 pontos', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.data( - ratePercent: 10.75, - variationPercent: 0, - sparklineData: [10.75], - caption: 'Atualizada pelo Banco Central hoje', - ))); - - expect(tester.takeException(), isNull); - }); - }); - - group('MetricCard — estado loading', () { - testWidgets('renderiza sem lançar exceção e sem mostrar valor nenhum', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.loading())); - await tester.pump(const Duration(milliseconds: 100)); - - expect(tester.takeException(), isNull); - expect(find.textContaining('%'), findsNothing); - - // Encerra a animação em loop antes do fim do teste, senão o - // pumpWidget seguinte falha por haver um Timer/Ticker pendente. - await tester.pump(const Duration(seconds: 1)); - }); - }); - - group('MetricCard — estado offline', () { - testWidgets('com cache disponível, mostra o último valor esmaecido', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.offline( - ratePercent: 10.75, - variationPercent: 0.25, - caption: 'Sem conexão · mostrando último valor salvo', - ))); - - expect(find.textContaining('10,75'), findsOneWidget); - expect(find.byIcon(Icons.wifi_off), findsOneWidget); - expect( - find.text('Sem conexão · mostrando último valor salvo'), - findsOneWidget, - ); - }); - - testWidgets('sem cache disponível, mostra estado vazio com retry', - (tester) async { - var tentouNovamente = false; - - await tester.pumpWidget(wrap(MetricCard.offline( - caption: 'Não foi possível carregar a ', - onTap: () => tentouNovamente = true, - ))); - - expect(find.text('Não foi possível carregar a '), findsOneWidget); - expect(find.text('Toque para tentar novamente'), findsOneWidget); - expect(find.textContaining('%'), findsNothing); - - await tester.tap(find.byKey(const Key('metric_card_container'))); - await tester.pump(); - - expect(tentouNovamente, isTrue); - }); - - testWidgets('sem cache e sem onTap, não mostra "tentar novamente"', - (tester) async { - await tester.pumpWidget(wrap(const MetricCard.offline( - caption: 'Não foi possível carregar a ', - ))); - - expect(find.text('Toque para tentar novamente'), findsNothing); - expect(find.byType(InkWell), findsNothing); - }); - }); -} \ No newline at end of file diff --git a/test/modules/home/presentation/components/selic_band_test.dart b/test/modules/home/presentation/components/selic_band_test.dart new file mode 100644 index 0000000..4c9588f --- /dev/null +++ b/test/modules/home/presentation/components/selic_band_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../../../app_icon_finder.dart'; +import 'package:real_calc/core/errors/messages.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/modules/home/presentation/components/selic_band.dart'; +import 'package:real_calc/modules/home/presentation/components/sparkline_painter.dart'; +import 'package:real_calc/modules/home/presentation/cubit/selic_cubit.dart'; +import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; + +final today = DateTime(2026, 11, 3); + +Metric fakeMetric({double variation = 0.25}) { + final dates = [ + for (var i = 60; i >= 0; i--) DateTime(today.year, today.month - i, 3), + ]; + return Metric( + anualRate: 13.75, + variationPercent: variation, + sparklineData: [for (var i = 0; i < dates.length; i++) 10 + i / 10], + sparklineDates: dates, + lastDate: today, + ); +} + +void main() { + late int retries; + late List periods; + + setUp(() { + retries = 0; + periods = []; + }); + + Future pump( + WidgetTester tester, + SelicState state, { + double width = 400, + double textScale = 1, + bool disableAnimations = false, + DateTime? now, + }) async { + tester.view.physicalSize = Size(width, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: MediaQuery( + data: MediaQueryData( + size: Size(width, 800), + textScaler: TextScaler.linear(textScale), + disableAnimations: disableAnimations, + ), + child: Scaffold( + backgroundColor: const Color(0xFF221F2E), + body: Padding( + padding: const EdgeInsets.all(24), + child: SelicBand( + state: state, + onRetry: () => retries++, + onPeriodChanged: periods.add, + now: () => now ?? today, + ), + ), + ), + ), + ), + ); + } + + SparklinePainter painter(WidgetTester tester) => + tester + .widget( + find.descendant( + of: find.byType(SelicBand), + matching: find.byType(CustomPaint), + ), + ) + .painter! + as SparklinePainter; + + group('SelicBand', () { + testWidgets('com dados: taxa, variação, gráfico e fonte', (tester) async { + await pump(tester, SelicLoaded(fakeMetric())); + + expect(find.text('Taxa Selic hoje'), findsOneWidget); + expect(find.text('13,75%'), findsOneWidget); + expect(find.text('ao ano'), findsOneWidget); + expect(find.text('0,25 ponto em relação à anterior'), findsOneWidget); + expect(findAppIcon(AppIcons.arrowUp), findsOneWidget); + expect( + find.text('Fonte: Banco Central, atualizada hoje.'), + findsOneWidget, + ); + expect(painter(tester).strokeWidth, 1.75); + expect(painter(tester).color, Colors.white); + expect( + tester.getSize(find.byType(CustomPaint).last).height, + SelicBand.chartHeight, + ); + }); + + testWidgets('queda mostra a seta para baixo', (tester) async { + await pump(tester, SelicLoaded(fakeMetric(variation: -0.5))); + + expect(findAppIcon(AppIcons.arrowDown), findsOneWidget); + expect(find.text('0,50 ponto em relação à anterior'), findsOneWidget); + }); + + testWidgets('fonte mostra a data quando não é de hoje', (tester) async { + await pump(tester, SelicLoaded(fakeMetric()), now: DateTime(2026, 11, 5)); + + expect( + find.text('Fonte: Banco Central, atualizada em 03/11.'), + findsOneWidget, + ); + }); + + testWidgets('a fonte usa a data em que o valor foi salvo', (tester) async { + await pump( + tester, + SelicLoaded(fakeMetric().copyWith(updatedAt: DateTime(2026, 10, 21))), + ); + + expect( + find.text('Fonte: Banco Central, atualizada em 21/10.'), + findsOneWidget, + ); + }); + + testWidgets('tocar no período avisa quem controla a faixa', (tester) async { + await pump(tester, SelicLoaded(fakeMetric())); + + expect( + find.bySemanticsLabel( + 'Gráfico da Selic nos últimos 12 meses, valor atual 13,75% ao ano', + ), + findsOneWidget, + ); + + await tester.tap(find.text('5 anos')); + expect(periods, [SelicPeriod.years5]); + }); + + testWidgets('desenha a série do período recebido', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, SelicLoaded(fakeMetric(), SelicPeriod.years5)); + + expect(painter(tester).data, hasLength(61)); + expect( + find.bySemanticsLabel( + 'Gráfico da Selic nos últimos 5 anos, valor atual 13,75% ao ano', + ), + findsOneWidget, + ); + expect( + tester.getSemantics(find.text('5 anos')), + isSemantics(isButton: true, isSelected: true), + ); + handle.dispose(); + }); + + testWidgets('carregando: esqueleto com shimmer', (tester) async { + await pump(tester, const SelicLoading()); + + expect(find.text('13,75%'), findsNothing); + expect(find.bySemanticsLabel('Carregando a taxa Selic'), findsOneWidget); + expect(tester.hasRunningAnimations, isTrue); + }); + + testWidgets('o shimmer para com "reduzir movimento"', (tester) async { + await pump(tester, const SelicInitial(), disableAnimations: true); + + expect(tester.hasRunningAnimations, isFalse); + }); + + testWidgets('sem conexão com o último valor', (tester) async { + await pump( + tester, + SelicError(ServerErrorMessages.connectionError, fakeMetric()), + ); + + expect(find.text('sem conexão'), findsOneWidget); + expect(findAppIcon(AppIcons.wifiOff), findsOneWidget); + expect(find.text('12 meses'), findsOneWidget); + expect(find.text('Último valor salvo, atualizada hoje.'), findsOneWidget); + final opacity = tester.widget( + find.ancestor(of: find.text('13,75%'), matching: find.byType(Opacity)), + ); + expect(opacity.opacity, 0.5); + + await tester.tap(find.text('Atualizar')); + expect(retries, 1); + }); + + testWidgets('valor do cache aparece como sem conexão, com a data', ( + tester, + ) async { + await pump( + tester, + SelicLoaded( + fakeMetric().copyWith( + offline: true, + updatedAt: DateTime(2026, 10, 21), + ), + ), + ); + + expect(find.text('sem conexão'), findsOneWidget); + expect( + find.text('Último valor salvo, atualizada em 21/10.'), + findsOneWidget, + ); + expect(find.byType(CustomPaint), findsWidgets); + }); + + testWidgets('5 anos sem cache: só o gráfico fica indisponível', ( + tester, + ) async { + await pump( + tester, + SelicError( + ServerErrorMessages.connectionError, + fakeMetric(), + SelicPeriod.years5, + true, + ), + ); + + expect(find.text('13,75%'), findsOneWidget); + expect( + find.text('Gráfico de 5 anos indisponível sem conexão.'), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(SelicBand), + matching: find.byWidgetPredicate( + (w) => w is CustomPaint && w.painter is SparklinePainter, + ), + ), + findsNothing, + ); + expect(find.text('Selic indisponível'), findsNothing); + }); + + testWidgets('sem conexão e sem dados', (tester) async { + await pump(tester, SelicError(ServerErrorMessages.connectionError)); + + expect(find.text('Selic indisponível'), findsOneWidget); + expect(find.text('Sem conexão com o Banco Central.'), findsOneWidget); + expect(findAppIcon(AppIcons.wifiOff), findsOneWidget); + + await tester.tap(find.text('Tentar de novo')); + expect(retries, 1); + }); + + testWidgets('em 360dp e fonte em 130%, título e períodos não se ' + 'sobrepõem e o número não é cortado', (tester) async { + await pump(tester, SelicLoaded(fakeMetric()), width: 360, textScale: 1.3); + + expect(tester.takeException(), isNull); + final title = tester.getRect(find.text('Taxa Selic hoje')); + final periods = tester.getRect(find.text('12 meses')); + expect(title.overlaps(periods), isFalse); + + final band = tester.getRect(find.byType(SelicBand)); + final rate = tester.getRect(find.text('13,75%')); + expect(rate.left, greaterThanOrEqualTo(band.left)); + expect(rate.right, lessThanOrEqualTo(band.right)); + }); + }); +} diff --git a/test/modules/home/presentation/cubit/selic_cubit_test.dart b/test/modules/home/presentation/cubit/selic_cubit_test.dart index da7bfe2..31cad95 100644 --- a/test/modules/home/presentation/cubit/selic_cubit_test.dart +++ b/test/modules/home/presentation/cubit/selic_cubit_test.dart @@ -1,82 +1,194 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:real_calc/core/errors/messages.dart'; +import 'package:real_calc/core/seed_works/error.dart'; import 'package:real_calc/core/seed_works/result.dart'; import 'package:real_calc/modules/home/presentation/cubit/selic_cubit.dart'; import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; import 'package:real_calc/modules/metrics/use_cases/selic/i_selic_use_case.dart'; class MockSelicUseCase extends Mock implements ISelicUseCase {} void main() { late MockSelicUseCase useCase; - late SelicCubit cubit; late Metric metric; + late Metric fiveYears; + + setUpAll(() => registerFallbackValue(SelicPeriod.months12)); setUp(() { useCase = MockSelicUseCase(); - cubit = SelicCubit(useCase); metric = Metric( anualRate: 10.75, variationPercent: 0.25, sparklineData: const [10.5, 10.6, 10.75], ); + fiveYears = Metric( + anualRate: 10.75, + variationPercent: 0.25, + sparklineData: const [6, 8, 10.75], + ); }); - tearDown(() => cubit.close()); + void answer(SelicPeriod period, Result result) { + when(() => useCase.getSelic(period)).thenAnswer((_) async => result); + } + + final offlineError = FailureResult( + ServerErrorMessages.connectionError, + ); group('SelicCubit', () { blocTest( - 'emite loading e loaded quando a consulta tem sucesso', - setUp: () { - when( - () => useCase.getSelicRate(), - ).thenAnswer((_) async => SuccessResult(metric)); - }, - build: () => cubit, + 'emite loading e loaded de 12 meses ao carregar', + setUp: () => answer(SelicPeriod.months12, SuccessResult(metric)), + build: () => SelicCubit(useCase), act: (cubit) => cubit.load(), expect: () => [ isA(), - isA().having((state) => state.metric, 'metric', metric), + isA() + .having((s) => s.metric, 'metric', metric) + .having((s) => s.period, 'period', SelicPeriod.months12), ], - verify: (_) { - verify(() => useCase.getSelicRate()).called(1); + verify: (_) => + verify(() => useCase.getSelic(SelicPeriod.months12)).called(1), + ); + + blocTest( + 'emite loading e error quando não há rede nem cache', + setUp: () => answer(SelicPeriod.months12, offlineError), + build: () => SelicCubit(useCase), + act: (cubit) => cubit.load(), + expect: () => [ + isA(), + isA() + .having( + (s) => s.error, + 'error', + ServerErrorMessages.connectionError, + ) + .having((s) => s.metric, 'metric', isNull) + .having((s) => s.chartUnavailable, 'chartUnavailable', isFalse), + ], + ); + + blocTest( + 'valor do cache chega como loaded e offline', + setUp: () => answer( + SelicPeriod.months12, + SuccessResult(metric.copyWith(offline: true)), + ), + build: () => SelicCubit(useCase), + act: (cubit) => cubit.load(), + expect: () => [ + isA(), + isA().having((s) => s.offline, 'offline', isTrue), + ], + ); + + blocTest( + 'changePeriod busca 5 anos mantendo a taxa durante o carregamento', + setUp: () { + answer(SelicPeriod.months12, SuccessResult(metric)); + answer(SelicPeriod.years5, SuccessResult(fiveYears)); }, + build: () => SelicCubit(useCase), + act: (cubit) async { + await cubit.load(); + await cubit.changePeriod(SelicPeriod.years5); + }, + skip: 2, + expect: () => [ + isA() + .having((s) => s.metric, 'metric', metric) + .having((s) => s.period, 'period', SelicPeriod.years5) + .having((s) => s.chartUnavailable, 'chartUnavailable', isTrue), + isA() + .having((s) => s.metric, 'metric', fiveYears) + .having((s) => s.period, 'period', SelicPeriod.years5), + ], ); blocTest( - 'emite loading e error quando a consulta falha', + '5 anos sem rede e sem cache: mantém a taxa e só o gráfico falta', setUp: () { - when(() => useCase.getSelicRate()).thenAnswer( - (_) async => FailureResult(ServerErrorMessages.connectionError), - ); + answer(SelicPeriod.months12, SuccessResult(metric)); + answer(SelicPeriod.years5, offlineError); }, - build: () => cubit, - act: (cubit) => cubit.load(), + build: () => SelicCubit(useCase), + act: (cubit) async { + await cubit.load(); + await cubit.changePeriod(SelicPeriod.years5); + }, + skip: 3, expect: () => [ - isA(), - isA().having( - (state) => state.error, - 'error', - ServerErrorMessages.connectionError, - ), + isA() + .having((s) => s.metric, 'metric', metric) + .having((s) => s.period, 'period', SelicPeriod.years5) + .having((s) => s.chartUnavailable, 'chartUnavailable', isTrue), ], ); blocTest( - 'retry consulta novamente a taxa Selic', + 'voltar para um período já carregado usa o valor dele', setUp: () { - when( - () => useCase.getSelicRate(), - ).thenAnswer((_) async => SuccessResult(metric)); + answer(SelicPeriod.months12, SuccessResult(metric)); + answer(SelicPeriod.years5, SuccessResult(fiveYears)); }, - build: () => cubit, + build: () => SelicCubit(useCase), + act: (cubit) async { + await cubit.load(); + await cubit.changePeriod(SelicPeriod.years5); + await cubit.changePeriod(SelicPeriod.months12); + }, + skip: 4, + expect: () => [ + isA() + .having((s) => s.metric, 'metric', metric) + .having((s) => s.chartUnavailable, 'chartUnavailable', isFalse), + isA().having((s) => s.metric, 'metric', metric), + ], + ); + + blocTest( + 'o mesmo período não consulta de novo', + setUp: () => answer(SelicPeriod.months12, SuccessResult(metric)), + build: () => SelicCubit(useCase), + act: (cubit) => cubit.changePeriod(SelicPeriod.months12), + expect: () => [], + verify: (_) => verifyNever(() => useCase.getSelic(any())), + ); + + test('resposta antiga não sobrescreve a troca mais recente', () async { + final slow = Completer>(); + when( + () => useCase.getSelic(SelicPeriod.years5), + ).thenAnswer((_) => slow.future); + answer(SelicPeriod.months12, SuccessResult(metric)); + final cubit = SelicCubit(useCase); + addTearDown(cubit.close); + + final first = cubit.changePeriod(SelicPeriod.years5); + await cubit.changePeriod(SelicPeriod.months12); + slow.complete(SuccessResult(fiveYears)); + await first; + + expect(cubit.state.period, SelicPeriod.months12); + expect(cubit.state.metric, metric); + }); + + blocTest( + 'retry consulta de novo o período atual', + setUp: () => answer(SelicPeriod.months12, SuccessResult(metric)), + build: () => SelicCubit(useCase), act: (cubit) => cubit.retry(), expect: () => [isA(), isA()], - verify: (_) { - verify(() => useCase.getSelicRate()).called(1); - }, + verify: (_) => + verify(() => useCase.getSelic(SelicPeriod.months12)).called(1), ); }); } diff --git a/test/modules/home/presentation/page/page_test.dart b/test/modules/home/presentation/page/page_test.dart index 86def5f..02c7bae 100644 --- a/test/modules/home/presentation/page/page_test.dart +++ b/test/modules/home/presentation/page/page_test.dart @@ -1,26 +1,22 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:real_calc/core/errors/messages.dart'; import 'package:real_calc/core/routes/app_routes.dart'; -import 'package:real_calc/core/seed_works/error.dart'; -import 'package:real_calc/core/themes/color_tokens.dart'; -import 'package:real_calc/core/themes/extensions/financing_forms_theme.dart'; -import 'package:real_calc/core/themes/extensions/help_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/home_page_theme.dart'; -import 'package:real_calc/core/themes/extensions/input_forms_result_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/menu_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/metric_card_theme.dart'; -import 'package:real_calc/core/themes/extensions/options_bottom_forms_theme.dart'; -import 'package:real_calc/core/themes/extensions/title_widget_theme.dart'; -import 'package:real_calc/core/themes/text_styles.dart'; -import 'package:real_calc/core/widgets/title_widget.dart'; -import 'package:real_calc/modules/home/presentation/components/menu_card.dart'; -import 'package:real_calc/modules/home/presentation/components/metric_card.dart'; +import 'package:real_calc/core/themes/app_colors.dart'; +import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/widgets/app_logo.dart'; +import 'package:real_calc/core/widgets/theme_toggle_button.dart'; +import 'package:real_calc/modules/home/presentation/components/calculator_list_item.dart'; +import 'package:real_calc/modules/home/presentation/components/selic_band.dart'; import 'package:real_calc/modules/home/presentation/cubit/greeting_cubit.dart'; import 'package:real_calc/modules/home/presentation/cubit/selic_cubit.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; import 'package:real_calc/modules/home/presentation/page/page.dart'; import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; @@ -31,125 +27,60 @@ class MockSelicCubit extends MockCubit implements SelicCubit {} class MockGreetingCubit extends MockCubit implements GreetingCubit {} void main() { - late MockNavigator mockNavigator; - late MockSelicCubit mockSelicCubit; - late MockGreetingCubit mockGreetingCubit; + setUpAll(() => registerFallbackValue(SelicPeriod.months12)); - setUpAll(() { - registerFallbackValue(SelicInitial()); - }); + late MockNavigator navigator; + late MockSelicCubit selicCubit; + late MockGreetingCubit greetingCubit; + + final metric = Metric( + anualRate: 13.75, + variationPercent: 0.25, + sparklineData: const [13.5, 13.75], + ); setUp(() { - mockNavigator = MockNavigator(); - Modular.navigatorDelegate = mockNavigator; + navigator = MockNavigator(); + Modular.navigatorDelegate = navigator; + when(() => navigator.pushNamed(any())).thenAnswer((_) async => null); - mockSelicCubit = MockSelicCubit(); + selicCubit = MockSelicCubit(); whenListen( - mockSelicCubit, + selicCubit, const Stream.empty(), - initialState: SelicLoaded( - Metric( - anualRate: 10.75, - variationPercent: 0.25, - sparklineData: const [10.5, 10.6, 10.75], - ), - ), + initialState: SelicLoaded(metric), ); + when(() => selicCubit.retry()).thenAnswer((_) async {}); - mockGreetingCubit = MockGreetingCubit(); + greetingCubit = MockGreetingCubit(); whenListen( - mockGreetingCubit, + greetingCubit, const Stream.empty(), initialState: 'Bom dia', ); }); - Widget buildTestableWidget({double width = 360.0, double height = 800.0}) { - return MaterialApp( - theme: ThemeData( - extensions: [ - OptionsBottomFormsTheme( - calculateButtonBackground: ColorTokens.accentAmber, - calculateButtonForeground: Colors.white, - actionButtonBackground: ColorTokens.surface, - actionButtonForeground: ColorTokens.textSecondary, - borderButtonColor: ColorTokens.successContainerBorder, - ), - TitleWidgetTheme( - titleStyle: AppTextStyles.headlineMedium, - subtitleStyle: AppTextStyles.bodyMedium, - ), - HelpCardTheme( - messageStyle: AppTextStyles.helpCardMessage, - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber, - iconBackgroundColor: ColorTokens.accentAmber, - iconColor: Colors.white, - ), - InputFormsResultCardTheme( - neutralBorderColor: ColorTokens.border, - neutralLabelColor: ColorTokens.textSecondary, - neutralIconColor: ColorTokens.textSecondary, - highlightedColor: ColorTokens.accentAmber, - calculatedColor: ColorTokens.success, - errorColor: ColorTokens.error, - badgeStyle: AppTextStyles.stateBadge, - ), - HomePageTheme( - scaffoldBackgroundColor: ColorTokens.background, - cardColor: ColorTokens.surface, - // ColorTokens.iconColor não existe no ColorTokens redefinido — - // confirme o nome certo do token no seu projeto (ex: textSecondary). - iconContainerColor: ColorTokens.textSecondary, - appNameStyle: AppTextStyles.appName, - sectionHeaderStyle: AppTextStyles.sectionHeader, - historyItemStyle: AppTextStyles.historyItem, - historyItemIconColor: Colors.white, - historyItemArrowColor: Colors.white, - bottomNavBackgroundColor: ColorTokens.surface, - bottomNavSelectedColor: ColorTokens.accentAmber, - bottomNavUnselectedColor: Colors.white, - lineColor: ColorTokens.surface, - ), - FinancingFormsTheme( - iconColor: ColorTokens.accentAmber, - progressIndicatorColor: ColorTokens.accentAmber, - errorBackgroundColor: ColorTokens.errorContainerBg, - errorBorderColor: ColorTokens.errorContainerBorder, - errorTextStyle: AppTextStyles.errorBannerText, - ), - MetricCardTheme( - backgroundColor: ColorTokens.surface, - borderColor: ColorTokens.accentAmber, - labelStyle: AppTextStyles.metricLabel, - valueStyle: AppTextStyles.metricValue, - valueUnitStyle: AppTextStyles.metricLabel, - captionStyle: AppTextStyles.metricCaption, - variationTextStyle: AppTextStyles.stateBadge, - variationPositiveColor: ColorTokens.success, - variationNegativeColor: ColorTokens.error, - variationNeutralColor: ColorTokens.textSecondary, - skeletonColor: ColorTokens.surfaceVariant, - ), - MenuCardTheme( - titleStyle: AppTextStyles.menuCardTitle, - descriptionStyle: AppTextStyles.menuCardDescription, - titleStyleCompact: AppTextStyles.menuCardTitleCompact, - descriptionStyleCompact: AppTextStyles.menuCardDescriptionCompact, - titleStyleFeatured: AppTextStyles.menuCardTitleFeatured, - descriptionStyleFeatured: AppTextStyles.menuCardDescriptionFeatured, - featuredBorderColor: ColorTokens.accentAmber, - disabledLabelColor: ColorTokens.textHint, - disabledTextStyle: AppTextStyles.menuCardDescription, - ), - ], - ), - home: MediaQuery( - data: MediaQueryData(size: Size(width, height)), - child: MultiBlocProvider( + Future pump( + WidgetTester tester, { + ThemeMode themeMode = ThemeMode.light, + Size size = const Size(400, 900), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: MultiBlocProvider( providers: [ - BlocProvider.value(value: mockSelicCubit), - BlocProvider.value(value: mockGreetingCubit), + BlocProvider.value(value: selicCubit), + BlocProvider.value(value: greetingCubit), ], child: const HomePage(), ), @@ -157,151 +88,162 @@ void main() { ); } - group('HomePage Widget Tests', () { - testWidgets( - 'Deve renderizar os componentes base estruturais e textos principais', - (tester) async { - await tester.pumpWidget(buildTestableWidget()); - - expect(find.byType(TitleWidget), findsOneWidget); - expect(find.byType(MetricCard), findsOneWidget); - expect(find.byType(MenuCard), findsNWidgets(4)); - }, - ); - - testWidgets( - 'Deve navegar para a tela de depósitos regulares ao clicar no card correspondente', - (tester) async { - when(() => mockNavigator.pushNamed(any())).thenAnswer((_) async => null); - await tester.pumpWidget(buildTestableWidget()); - - final cardFinanciamento = - find.widgetWithText(MenuCard, 'Depósitos Regulares'); - expect(cardFinanciamento, findsOneWidget); - - await tester.tap(cardFinanciamento); - await tester.pumpAndSettle(); - - verify(() => mockNavigator.pushNamed(AppRoutes.deposits)).called(1); - }, - ); - - testWidgets( - 'Deve navegar para a tela de valor futuro ao clicar no card correspondente', - (tester) async { - when(() => mockNavigator.pushNamed(any())).thenAnswer((_) async => null); - await tester.pumpWidget(buildTestableWidget()); - - final cardValorFuturo = find.widgetWithText(MenuCard, 'Valor Futuro'); - expect(cardValorFuturo, findsOneWidget); - - await tester.tap(cardValorFuturo); - await tester.pumpAndSettle(); - - verify(() => mockNavigator.pushNamed(AppRoutes.futureValue)).called(1); - }, - ); - - testWidgets( - 'Deve navegar para a tela de financiamento ao clicar no card correspondente', - (tester) async { - when(() => mockNavigator.pushNamed(any())).thenAnswer((_) async => null); - await tester.pumpWidget(buildTestableWidget()); - - final cardFinanciamento = find.widgetWithText(MenuCard, 'Financiamento'); - expect(cardFinanciamento, findsOneWidget); - - await tester.tap(cardFinanciamento); - await tester.pumpAndSettle(); - - verify(() => mockNavigator.pushNamed(AppRoutes.financing)).called(1); - }, - ); - - testWidgets( - 'Deve renderizar o divisor e os cards em duas colunas no mobile', - (tester) async { - await tester.pumpWidget(buildTestableWidget(width: 360.0)); - - expect(find.byType(VerticalDivider), findsOneWidget); - expect(find.byType(MenuCard), findsNWidgets(4)); - expect(tester.takeException(), isNull); - }, - ); - - testWidgets( - 'Deve manter os cards e o divisor em telas largas', - (tester) async { - await tester.pumpWidget(buildTestableWidget(width: 1024.0)); - - expect(find.byType(VerticalDivider), findsOneWidget); - expect(find.byType(MenuCard), findsNWidgets(4)); - expect(tester.takeException(), isNull); - }, - ); - }); - - group('HomePage — estados do card de métrica (SelicCubit)', () { - testWidgets('mostra o skeleton quando o estado é SelicLoading', (tester) async { - whenListen( - mockSelicCubit, - const Stream.empty(), - initialState: SelicLoading(), + group('HomePage', () { + testWidgets('faixa com logo, tema e Selic, e ícones claros na barra de ' + 'status', (tester) async { + await pump(tester); + + expect(find.byType(AppLogo), findsOneWidget); + expect(find.byType(ThemeToggleButton), findsOneWidget); + expect(find.byType(SelicBand), findsOneWidget); + expect(find.text('13,75%'), findsOneWidget); + + final band = tester.widget( + find + .ancestor( + of: find.byType(AppLogo), + matching: find.byType(ColoredBox), + ) + .first, ); + expect(band.color, AppColors.light.bandHome); + expect(tester.getTopLeft(find.byWidget(band)).dy, 0); + + final region = tester.widget>( + find + .ancestor( + of: find.byType(AppLogo), + matching: find.byType(AnnotatedRegion), + ) + .first, + ); + expect(region.value.statusBarIconBrightness, Brightness.light); + }); - await tester.pumpWidget(buildTestableWidget()); - await tester.pump(const Duration(milliseconds: 100)); + testWidgets('saudação, título, lista e rodapé', (tester) async { + await pump(tester); + + expect(find.text('Bom dia'), findsOneWidget); + expect(find.text('O que vamos calcular hoje?'), findsOneWidget); + expect(find.byType(CalculatorListItem), findsNWidgets(4)); + for (final (title, description) in [ + ('Financiamento', 'Prestação, prazo ou taxa'), + ('Depósitos Regulares', 'Quanto você acumula todo mês'), + ('Valor Futuro', 'Quanto rende um valor aplicado'), + ('Correção de Valores', 'Pela inflação, juros ou poupança'), + ]) { + expect(find.text(title), findsOneWidget); + expect(find.text(description), findsOneWidget); + } + expect(find.text(HomePage.footer), findsOneWidget); + }); - expect(find.byType(MetricCard), findsOneWidget); - // Não afirmamos o construtor exato (.loading) aqui porque MetricCard - // não expõe publicamente qual variante está ativa — só confirmamos - // que nenhum valor numérico da Selic aparece nesse estado. - expect(find.textContaining('%'), findsNothing); + testWidgets('itens de 72px com o quadrado na cor de cada calculadora', ( + tester, + ) async { + await pump(tester); + + for (final item in tester.widgetList( + find.byType(CalculatorListItem), + )) { + final finder = find.byWidget(item); + expect(tester.getSize(finder).height, greaterThanOrEqualTo(72)); + final tile = tester.widget( + find.descendant(of: finder, matching: find.byType(Container)).at(1), + ); + expect( + (tile.decoration! as BoxDecoration).color, + CalculatorPalette.of(item.kind).solid, + ); + } }); - testWidgets('mostra o valor quando o estado é SelicLoaded', (tester) async { - final metric = Metric( - anualRate: 10.75, - variationPercent: 0.25, - sparklineData: const [10.5, 10.6, 10.75], + for (final (title, route) in [ + ('Financiamento', AppRoutes.financing), + ('Depósitos Regulares', AppRoutes.deposits), + ('Valor Futuro', AppRoutes.futureValue), + ('Correção de Valores', AppRoutes.correction), + ]) { + testWidgets('tocar em qualquer parte de "$title" abre a calculadora', ( + tester, + ) async { + await pump(tester); + + final item = find.ancestor( + of: find.text(title), + matching: find.byType(CalculatorListItem), + ); + await tester.ensureVisible(item); + await tester.tapAt(tester.getTopRight(item) + const Offset(-4, 4)); + + verify(() => navigator.pushNamed(route)).called(1); + }); + } + + testWidgets('o TalkBack lê nome e descrição como um botão', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester); + + expect( + tester.getSemantics( + find.ancestor( + of: find.text('Valor Futuro'), + matching: find.byType(CalculatorListItem), + ), + ), + isSemantics( + label: 'Valor Futuro\nQuanto rende um valor aplicado', + isButton: true, + hasTapAction: true, + ), ); + handle.dispose(); + }); + + testWidgets('"Tentar de novo" na Selic chama o retry', (tester) async { whenListen( - mockSelicCubit, + selicCubit, const Stream.empty(), - initialState: SelicLoaded(metric), + initialState: SelicError(ServerErrorMessages.connectionError), ); + await pump(tester); + + await tester.tap(find.text('Tentar de novo')); + verify(() => selicCubit.retry()).called(1); + }); - await tester.pumpWidget(buildTestableWidget()); - await tester.pump(); + testWidgets('"5 anos" na Selic chama o changePeriod', (tester) async { + when(() => selicCubit.changePeriod(any())).thenAnswer((_) async {}); + await pump(tester); - expect(find.textContaining('10,75'), findsOneWidget); + await tester.tap(find.text('5 anos')); + verify(() => selicCubit.changePeriod(SelicPeriod.years5)).called(1); }); - testWidgets( - 'mostra a mensagem de erro quando o estado é SelicError', - (tester) async { - whenListen( - mockSelicCubit, - const Stream.empty(), - initialState: const SelicError( - _FakeErrorMessage.noConnection, - ), + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('em 360dp e fonte em 130% no tema ${themeMode.name}, as ' + 'descrições quebram sem cortar', (tester) async { + await pump( + tester, + themeMode: themeMode, + size: const Size(360, 780), + textScale: 1.3, ); - await tester.pumpWidget(buildTestableWidget()); - await tester.pump(); + expect(tester.takeException(), isNull); - expect(find.text('Sem conexão'), findsOneWidget); - }, - ); + final description = find.text('Quanto você acumula todo mês'); + await tester.ensureVisible(description); + final item = tester.getRect( + find.ancestor( + of: description, + matching: find.byType(CalculatorListItem), + ), + ); + final text = tester.getRect(description); + expect(text.height, greaterThan(13 * 1.3 * 1.5)); + expect(text.right, lessThanOrEqualTo(item.right)); + }); + } }); } - -enum _FakeErrorMessage implements ErrorMessages { - noConnection('Sem conexão'); - - const _FakeErrorMessage(this.message); - - @override - final String message; -} \ No newline at end of file diff --git a/test/modules/metrics/infrastructure/repositories/selic_cache_repository_test.dart b/test/modules/metrics/infrastructure/repositories/selic_cache_repository_test.dart new file mode 100644 index 0000000..d5af4b0 --- /dev/null +++ b/test/modules/metrics/infrastructure/repositories/selic_cache_repository_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; +import 'package:real_calc/modules/metrics/infrastructure/repositories/selic_cache_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + late SelicCacheRepository cache; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + cache = SelicCacheRepository(); + }); + + final metric = Metric( + anualRate: 14.75, + variationPercent: -0.25, + sparklineData: const [15, 14.75], + sparklineDates: [DateTime(2026, 10, 1), DateTime(2026, 11, 3)], + lastDate: DateTime(2026, 11, 3), + updatedAt: DateTime(2026, 11, 3, 10, 30), + ); + + test('sem nada salvo, devolve null', () async { + expect(await cache.read(SelicPeriod.months12), isNull); + }); + + test('guarda e lê o Metric de cada período em JSON', () async { + await cache.save(SelicPeriod.years5, metric); + + final read = (await cache.read(SelicPeriod.years5))!; + expect(read.anualRate, 14.75); + expect(read.variationPercent, -0.25); + expect(read.sparklineData, [15, 14.75]); + expect(read.sparklineDates, metric.sparklineDates); + expect(read.lastDate, DateTime(2026, 11, 3)); + expect(read.updatedAt, DateTime(2026, 11, 3, 10, 30)); + expect(read.offline, isFalse); + expect(await cache.read(SelicPeriod.months12), isNull); + + final preferences = await SharedPreferences.getInstance(); + expect( + preferences.getString(SelicCacheRepository.keyFor(SelicPeriod.years5)), + contains('"anualRate":14.75'), + ); + }); + + test('JSON corrompido é ignorado', () async { + SharedPreferences.setMockInitialValues({ + SelicCacheRepository.keyFor(SelicPeriod.months12): '{"anualRate":', + }); + + expect(await SelicCacheRepository().read(SelicPeriod.months12), isNull); + }); +} diff --git a/test/modules/metrics/use_cases/selic/selic_use_case_test.dart b/test/modules/metrics/use_cases/selic/selic_use_case_test.dart index 3abebd8..1dabf06 100644 --- a/test/modules/metrics/use_cases/selic/selic_use_case_test.dart +++ b/test/modules/metrics/use_cases/selic/selic_use_case_test.dart @@ -4,21 +4,187 @@ import 'package:real_calc/core/errors/messages.dart'; import 'package:real_calc/core/seed_works/error.dart'; import 'package:real_calc/core/seed_works/result.dart'; import 'package:real_calc/modules/metrics/domain/entites/metric.dart'; +import 'package:real_calc/modules/metrics/domain/enum/selic_period.dart'; +import 'package:real_calc/modules/metrics/domain/repositories/selic/i_selic_cache_repository.dart'; import 'package:real_calc/modules/metrics/domain/repositories/selic/i_selic_network_only_repository.dart'; import 'package:real_calc/modules/metrics/use_cases/selic/selic_use_case.dart'; import 'package:real_calc/modules/metrics/use_cases/selic/selic_validation.dart'; import 'package:real_calc/shared/network/response/selic_bc_response_network.dart'; -class MockSelicRepository extends Mock - implements ISelicNetworkOnlyRepository {} +class MockSelicRepository extends Mock implements ISelicNetworkOnlyRepository {} + +class MockSelicCache extends Mock implements ISelicCacheRepository {} void main() { + final now = DateTime(2026, 11, 3, 10); late MockSelicRepository repository; + late MockSelicCache cache; late SelicUseCase useCase; + setUpAll(() { + registerFallbackValue(SelicPeriod.months12); + registerFallbackValue( + Metric(anualRate: 0, variationPercent: 0, sparklineData: const []), + ); + }); + setUp(() { repository = MockSelicRepository(); - useCase = SelicUseCase(repository, SelicValidation()); + cache = MockSelicCache(); + when(() => cache.save(any(), any())).thenAnswer((_) async {}); + when(() => cache.read(any())).thenAnswer((_) async => null); + useCase = SelicUseCase( + repository, + SelicValidation(), + cache, + now: () => now, + ); + }); + + Metric cached() => Metric( + anualRate: 15, + variationPercent: 0, + sparklineData: const [15, 15], + updatedAt: DateTime(2026, 10, 21), + ); + + void networkFails() { + when(() => repository.getSelicRate()).thenAnswer( + (_) async => FailureResult>( + ServerErrorMessages.connectionError, + ), + ); + when( + () => repository.getSelicRateByPeriod( + dataInicial: any(named: 'dataInicial'), + dataFinal: any(named: 'dataFinal'), + ), + ).thenAnswer( + (_) async => FailureResult>( + ServerErrorMessages.connectionError, + ), + ); + } + + group('getSelic', () { + test( + 'rede ok grava o cache do período com a data de atualização', + () async { + when(() => repository.getSelicRate()).thenAnswer( + (_) async => + SuccessResult>([ + const SelicBcResponseNetwork( + data: '02/11/2026', + valor: '15.00', + ), + const SelicBcResponseNetwork( + data: '03/11/2026', + valor: '14.75', + ), + ]), + ); + + final metric = (await useCase.getSelic( + SelicPeriod.months12, + )).getOrNull()!; + + expect(metric.anualRate, 14.75); + expect(metric.offline, isFalse); + expect(metric.updatedAt, now); + final saved = + verify( + () => cache.save(SelicPeriod.months12, captureAny()), + ).captured.single + as Metric; + expect(saved.anualRate, 14.75); + expect(saved.updatedAt, now); + }, + ); + + test('rede com falha e cache devolve o valor salvo sem conexão', () async { + networkFails(); + when( + () => cache.read(SelicPeriod.months12), + ).thenAnswer((_) async => cached()); + + final metric = (await useCase.getSelic( + SelicPeriod.months12, + )).getOrNull()!; + + expect(metric.offline, isTrue); + expect(metric.anualRate, 15); + expect(metric.updatedAt, DateTime(2026, 10, 21)); + verifyNever(() => cache.save(any(), any())); + }); + + test('rede com falha e sem cache devolve o erro', () async { + networkFails(); + + final result = await useCase.getSelic(SelicPeriod.years5); + + expect(result.getErrorOrNull(), ServerErrorMessages.connectionError); + verify(() => cache.read(SelicPeriod.years5)).called(1); + }); + + test('o cache de um período não serve para o outro', () async { + networkFails(); + when( + () => cache.read(SelicPeriod.months12), + ).thenAnswer((_) async => cached()); + + final result = await useCase.getSelic(SelicPeriod.years5); + + expect(result.isError, isTrue); + }); + + test('5 anos usa o período e reduz a série a um ponto por mês', () async { + final start = DateTime(2021, 11, 3); + final points = [ + for (var d = start; !d.isAfter(now); d = d.add(const Duration(days: 1))) + SelicBcResponseNetwork( + data: + '${d.day.toString().padLeft(2, '0')}/' + '${d.month.toString().padLeft(2, '0')}/${d.year}', + valor: '${10 + d.month / 100}', + ), + ]; + when( + () => repository.getSelicRateByPeriod( + dataInicial: DateTime(2021, 11, 3), + dataFinal: now, + ), + ).thenAnswer( + (_) async => + SuccessResult>(points), + ); + + final metric = (await useCase.getSelic(SelicPeriod.years5)).getOrNull()!; + + expect(points.length, greaterThan(1000)); + expect(metric.sparklineData, hasLength(SelicUseCase.maxChartPoints)); + expect(metric.sparklineDates, hasLength(SelicUseCase.maxChartPoints)); + final months = { + for (final date in metric.sparklineDates) (date.year, date.month), + }; + expect(months, hasLength(SelicUseCase.maxChartPoints)); + expect(metric.sparklineDates.last, DateTime(2026, 11, 3)); + expect(metric.lastDate, DateTime(2026, 11, 3)); + verifyNever(() => repository.getSelicRate()); + verify(() => cache.save(SelicPeriod.years5, any())).called(1); + }); + + test('falha ao gravar o cache não derruba o valor da rede', () async { + when(() => repository.getSelicRate()).thenAnswer( + (_) async => SuccessResult>( + [const SelicBcResponseNetwork(data: '03/11/2026', valor: '14.75')], + ), + ); + when(() => cache.save(any(), any())).thenThrow(Exception('disco cheio')); + + final result = await useCase.getSelic(SelicPeriod.months12); + + expect(result.getOrNull()!.anualRate, 14.75); + }); }); test('mapeia os dados do repository para Metric', () async { @@ -38,6 +204,23 @@ void main() { expect(metric.sparklineData, [10.0, 10.5]); }); + test('guarda as datas dos pontos e a data da última atualização', () async { + when(() => repository.getSelicRate()).thenAnswer( + (_) async => SuccessResult>([ + const SelicBcResponseNetwork(data: '30/10/2026', valor: '15.00'), + const SelicBcResponseNetwork(data: '03/11/2026', valor: '14.75'), + ]), + ); + + final metric = (await useCase.getSelicRate()).getOrNull()!; + + expect(metric.sparklineDates, [ + DateTime(2026, 10, 30), + DateTime(2026, 11, 3), + ]); + expect(metric.lastDate, DateTime(2026, 11, 3)); + }); + test('não consulta o repository quando o período é inválido', () async { final result = await useCase.getSelicRateByPeriod( dataInicial: DateTime(2026, 1, 1), diff --git a/test/modules/value_correction/domain/enum/correction_index_test.dart b/test/modules/value_correction/domain/enum/correction_index_test.dart new file mode 100644 index 0000000..c26e10e --- /dev/null +++ b/test/modules/value_correction/domain/enum/correction_index_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/modules/value_correction/domain/enum/correction_index.dart'; + +void main() { + group('CorrectionIndex', () { + test('o nome não carrega a fonte entre parênteses', () { + for (final index in CorrectionIndex.values) { + expect(index.label, isNot(contains('('))); + expect(index.source, isNotEmpty); + } + expect(CorrectionIndex.ipca.label, 'IPCA'); + expect(CorrectionIndex.ipca.source, 'IBGE'); + expect(CorrectionIndex.igpM.source, 'FGV'); + }); + + test('sinceMonthLabel usa mm/aaaa mesmo nas séries diárias', () { + expect(CorrectionIndex.ipcSp.sinceMonthLabel, '11/1942'); + expect(CorrectionIndex.selic.sinceMonthLabel, '06/1986'); + expect(CorrectionIndex.taxaLegal.sinceMonthLabel, '08/2024'); + }); + }); +} diff --git a/test/modules/value_correction/infrastructure/repositories/sgs_repository_test.dart b/test/modules/value_correction/infrastructure/repositories/sgs_repository_test.dart index e035570..aa15a51 100644 --- a/test/modules/value_correction/infrastructure/repositories/sgs_repository_test.dart +++ b/test/modules/value_correction/infrastructure/repositories/sgs_repository_test.dart @@ -48,6 +48,35 @@ void main() { }, ); + test( + 'consulta o IGP-M histórico de alta precisão pela série SGS 28655', + () async { + when(() => network.get>(any())).thenAnswer( + (_) async => SuccessResult>([ + {'data': '01/01/2015', 'valor': '0.7647618382'}, + {'data': '01/12/2020', 'valor': '0.9581082789'}, + ]), + ); + + final result = await repository.getSeries( + index: CorrectionIndex.igpM, + start: DateTime(2015, 1, 1), + end: DateTime(2020, 12, 1), + ); + + expect(result.isSuccess, isTrue); + expect(result.getOrNull(), [ + SeriesPoint(date: DateTime(2015, 1, 1), value: 0.7647618382), + SeriesPoint(date: DateTime(2020, 12, 1), value: 0.9581082789), + ]); + verify( + () => network.get>( + any(that: contains('bcdata.sgs.28655')), + ), + ).called(1); + }, + ); + test('deve ignorar pontos com valor inválido', () async { when(() => network.get>(any())).thenAnswer( (_) async => SuccessResult>([ diff --git a/test/modules/value_correction/module_test.dart b/test/modules/value_correction/module_test.dart index 2faac50..92bd120 100644 --- a/test/modules/value_correction/module_test.dart +++ b/test/modules/value_correction/module_test.dart @@ -2,13 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/date_symbol_data_local.dart'; -import 'package:real_calc/core/routes/app_routes.dart'; import 'package:real_calc/core/themes/app_theme.dart'; -import 'package:real_calc/modules/value_correction/domain/entites/period.dart'; -import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; import 'package:real_calc/modules/value_correction/module.dart'; import 'package:real_calc/modules/value_correction/presentation/pages/value_correction_page.dart'; -import 'package:real_calc/modules/value_correction/presentation/pages/value_correction_result_page.dart'; void main() { setUpAll(() async { @@ -43,40 +39,5 @@ void main() { expect(find.byType(ValueCorrectionPage), findsOneWidget); }, ); - - testWidgets( - 'abre a página de resultado pela rota nomeada com os argumentos', - (tester) async { - await tester.pumpWidget( - MaterialApp.router( - theme: AppTheme.darkTheme, - routerConfig: Modular.routerConfig, - ), - ); - await tester.pumpAndSettle(); - - final result = ValueCorrection( - index: 433, - period: Period( - initial: DateTime(2024, 1, 1), - end: DateTime(2024, 12, 1), - ), - percentage: 0, - originalValue: 100, - factor: 1.015, - adjustedValue: 101.5, - variation: 1.5, - ); - - Modular.to.pushNamed( - '/${AppRoutes.correctionResultSegment}', - arguments: result, - ); - await tester.pumpAndSettle(); - - expect(find.byType(ValueCorrectionResultPage), findsOneWidget); - expect(find.textContaining('101,50'), findsOneWidget); - }, - ); }); } diff --git a/test/modules/value_correction/presentation/cubit/forms/validation/correction_form_validator_test.dart b/test/modules/value_correction/presentation/cubit/forms/validation/correction_form_validator_test.dart index 0ac29fe..7cb24a0 100644 --- a/test/modules/value_correction/presentation/cubit/forms/validation/correction_form_validator_test.dart +++ b/test/modules/value_correction/presentation/cubit/forms/validation/correction_form_validator_test.dart @@ -65,7 +65,7 @@ void main() { ); expect( unavailable.warningBannerMessage, - 'O índice IPCA-E (IBGE) possui dados a partir de 01/1992.', + 'O índice IPCA-E possui dados a partir de 01/1992.', ); expect(unavailable.canCalculate, isFalse); diff --git a/test/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit_test.dart b/test/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit_test.dart index 1f8b44a..6dd4ffb 100644 --- a/test/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit_test.dart +++ b/test/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit_test.dart @@ -67,13 +67,13 @@ void main() { expect(cubit.state.fieldErrors[ValueCorrectionField.initialDate], isNull); expect( cubit.state.warningBannerMessage, - 'O índice IPCA-E (IBGE) possui dados a partir de 01/1992.', + 'O índice IPCA-E possui dados a partir de 01/1992.', ); expect(cubit.state.canCalculate, isFalse); cubit.setIndex(CorrectionIndex.igpM); - cubit.initialDate.text = '04/2021'; - cubit.finalDate.text = '04/2021'; + cubit.initialDate.text = '01/2015'; + cubit.finalDate.text = '12/2020'; expect(cubit.state.fieldErrors[ValueCorrectionField.initialDate], isNull); expect(cubit.state.warningBannerMessage, isEmpty); diff --git a/test/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit_test.dart b/test/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit_test.dart index f1f6c22..6bcfe47 100644 --- a/test/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit_test.dart +++ b/test/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit_test.dart @@ -297,7 +297,7 @@ void main() { isA().having( (state) => state.message, 'message', - 'O índice IPCA-E (IBGE) possui dados a partir de 01/1992.', + 'O índice IPCA-E possui dados a partir de 01/1992.', ), ], verify: (_) { diff --git a/test/modules/value_correction/presentation/pages/value_correction_page_test.dart b/test/modules/value_correction/presentation/pages/value_correction_page_test.dart index eedbaab..8f2277f 100644 --- a/test/modules/value_correction/presentation/pages/value_correction_page_test.dart +++ b/test/modules/value_correction/presentation/pages/value_correction_page_test.dart @@ -1,31 +1,35 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:mocktail/mocktail.dart'; import 'package:real_calc/core/errors/failures.dart'; import 'package:real_calc/core/errors/messages.dart'; -import 'package:real_calc/core/routes/app_routes.dart'; import 'package:real_calc/core/seed_works/error.dart'; import 'package:real_calc/core/seed_works/result.dart'; +import 'package:real_calc/core/services/result_sharer.dart'; import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/widgets/bottom_action_bar.dart'; +import 'package:real_calc/core/widgets/calculator_band/calculator_band.dart'; +import 'package:real_calc/core/widgets/result_details/result_details.dart'; import 'package:real_calc/modules/value_correction/domain/entites/period.dart'; import 'package:real_calc/modules/value_correction/domain/entites/series_point.dart'; import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; import 'package:real_calc/modules/value_correction/domain/enum/correction_index.dart'; -import 'package:real_calc/modules/value_correction/domain/validation/value_correction_validation.dart'; import 'package:real_calc/modules/value_correction/domain/enum/series_kind.dart'; import 'package:real_calc/modules/value_correction/domain/repositories/i_correction_series_repository.dart'; -import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit.dart'; -import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/request_parsing/value_correction_request_parser.dart'; -import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/request_validation/value_correction_request_validator.dart'; -import 'package:real_calc/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit.dart'; +import 'package:real_calc/modules/value_correction/domain/validation/value_correction_validation.dart'; import 'package:real_calc/modules/value_correction/presentation/cubit/forms/date_formatting/correction_form_input_formatter.dart'; import 'package:real_calc/modules/value_correction/presentation/cubit/forms/date_parsing/correction_form_date_parser.dart'; import 'package:real_calc/modules/value_correction/presentation/cubit/forms/validation/correction_form_validator.dart'; +import 'package:real_calc/modules/value_correction/presentation/cubit/forms/value_correction_form_cubit.dart'; +import 'package:real_calc/modules/value_correction/presentation/cubit/forms/value_correction_form_state.dart'; +import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/request_parsing/value_correction_request_parser.dart'; +import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/request_validation/value_correction_request_validator.dart'; +import 'package:real_calc/modules/value_correction/presentation/cubit/value_correction/value_correction_cubit.dart'; import 'package:real_calc/modules/value_correction/presentation/pages/value_correction_page.dart'; -import 'package:real_calc/modules/value_correction/presentation/pages/value_correction_result_page.dart'; import 'package:real_calc/modules/value_correction/use_cases/i_calculate_value_correction.dart'; class MockCorrectionSeriesRepository extends Mock @@ -34,26 +38,32 @@ class MockCorrectionSeriesRepository extends Mock class MockCalculateValueCorrection extends Mock implements ICalculateValueCorrection {} -class MockNavigator extends Mock implements IModularNavigator {} - class ValueCorrectionFake extends Fake implements ValueCorrection {} -ValueCorrectionFormCubit buildFormCubit() => ValueCorrectionFormCubit( - CorrectionFormInputFormatter(), - CorrectionFormValidator(CorrectionFormDateParser()), -); +class FakeSharer implements ResultSharer { + final shared = []; + + @override + Future share(String text, {Rect? origin}) async => shared.add(text); +} -ValueCorrectionCubit buildValueCorrectionCubit( - ICorrectionSeriesRepository repository, - ICalculateValueCorrection calculateValueCorrection, -) => ValueCorrectionCubit( - repository, - calculateValueCorrection, - ValueCorrectionRequestParser(CorrectionFormDateParser()), - ValueCorrectionRequestValidator(ValueCorrectionValidation()), +ValueCorrection ipcaResult({bool withValue = true}) => ValueCorrection( + index: CorrectionIndex.ipca.sgsCode, + period: Period(initial: DateTime(2001, 12), end: DateTime(2003, 12)), + percentage: 100, + originalValue: withValue ? 5555.55 : null, + factor: 1.2379507, + adjustedValue: withValue ? 6877.498 : null, + variation: 23.79507, ); void main() { + late MockCorrectionSeriesRepository repository; + late MockCalculateValueCorrection calculate; + late ValueCorrectionFormCubit formCubit; + late ValueCorrectionCubit correctionCubit; + late FakeSharer sharer; + setUpAll(() async { await initializeDateFormatting('pt_BR', null); registerFallbackValue(CorrectionIndex.ipca); @@ -61,379 +71,362 @@ void main() { registerFallbackValue(ValueCorrectionFake()); }); - group('ValueCorrectionPage', () { - late MockCorrectionSeriesRepository seriesRepository; - late MockCalculateValueCorrection calculateValueCorrection; - late MockNavigator navigator; - - Widget buildSut() { - return BlocProvider( - create: (_) => buildFormCubit()..setIndex(CorrectionIndex.ipca), - child: MaterialApp( - theme: AppTheme.darkTheme, - home: BlocProvider( - create: (_) => buildValueCorrectionCubit( - seriesRepository, - calculateValueCorrection, - ), - child: const ValueCorrectionPage(), - ), + void seriesAnswers( + Future>> Function() answer, + ) { + when( + () => repository.getSeries( + index: any(named: 'index'), + start: any(named: 'start'), + end: any(named: 'end'), + ), + ).thenAnswer((_) => answer()); + } + + void calculationReturns(ValueCorrection result) { + when( + () => calculate.call( + params: any(named: 'params'), + series: any(named: 'series'), + type: any(named: 'type'), + ), + ).thenReturn(SuccessResult(result)); + } + + setUp(() { + repository = MockCorrectionSeriesRepository(); + calculate = MockCalculateValueCorrection(); + sharer = FakeSharer(); + formCubit = ValueCorrectionFormCubit( + CorrectionFormInputFormatter(), + CorrectionFormValidator(CorrectionFormDateParser()), + )..setIndex(CorrectionIndex.ipca); + correctionCubit = ValueCorrectionCubit( + repository, + calculate, + ValueCorrectionRequestParser(CorrectionFormDateParser()), + ValueCorrectionRequestValidator(ValueCorrectionValidation()), + ); + seriesAnswers( + () async => SuccessResult>([ + SeriesPoint(date: DateTime(2002, 1, 1), value: 0.5), + ]), + ); + calculationReturns(ipcaResult()); + }); + + tearDown(() async { + await formCubit.close(); + await correctionCubit.close(); + }); + + Future pump( + WidgetTester tester, { + ThemeMode themeMode = ThemeMode.light, + Size size = const Size(400, 900), + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: MultiBlocProvider( + providers: [ + BlocProvider.value(value: formCubit), + BlocProvider.value(value: correctionCubit), + ], + child: ValueCorrectionPage(sharer: sharer), ), - ); - } + ), + ); + } - setUp(() { - seriesRepository = MockCorrectionSeriesRepository(); - calculateValueCorrection = MockCalculateValueCorrection(); - navigator = MockNavigator(); - Modular.navigatorDelegate = navigator; - when( - () => navigator.pushNamed(any(), arguments: any(named: 'arguments')), - ).thenAnswer((_) async => null); - }); + Finder inBand(String text) => find.descendant( + of: find.byType(CalculatorBand), + matching: find.text(text), + ); - testWidgets('renderiza a árvore do formulário e os botões da correção', ( - tester, - ) async { - await tester.pumpWidget(buildSut()); - - expect(find.text('Correção de Valores'), findsNWidgets(2)); - expect(find.textContaining('Escolha o índice'), findsOneWidget); - expect(find.text('Índice de correção'), findsOneWidget); - expect(find.text('Data inicial'), findsOneWidget); - expect(find.text('Data final'), findsOneWidget); - expect(find.text('Valor a ser corrigido (R\$)'), findsOneWidget); - expect(find.text('Corrigir valor'), findsOneWidget); - expect(find.text('Limpar'), findsOneWidget); - }); + Finder row(ValueCorrectionField field) => find.byKey(ValueKey(field)); - testWidgets('permite scroll na página com o teclado aberto', ( - tester, - ) async { - await tester.pumpWidget(buildSut()); + Future fillExample(WidgetTester tester, {bool withValue = true}) async { + formCubit.initialDate.text = '12/2001'; + formCubit.finalDate.text = '12/2003'; + if (withValue) formCubit.value.text = '5.555,55'; + await tester.pump(); + } - expect(find.byType(Scrollable), findsWidgets); - await tester.pump(); - expect(find.byType(Scrollable), findsWidgets); + Future correct(WidgetTester tester) async { + await tester.tap(find.text('Corrigir valor')); + await tester.pumpAndSettle(); + } + + group('ValueCorrectionPage', () { + testWidgets('monta faixa, campos e barra de ações', (tester) async { + await pump(tester); + + expect(find.text('Correção de Valores'), findsOneWidget); + expect(inBand('o seu dinheiro?'), findsOneWidget); + for (final label in ['Índice', 'De', 'Até', 'Valor']) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('IPCA'), findsOneWidget); + expect(find.text('opcional'), findsOneWidget); + expect(find.text('Percentual'), findsNothing); + expect(find.text('O IPCA tem dados desde 01/1980.'), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); }); - testWidgets( - 'o aviso de moeda informa a disponibilidade do índice selecionado', - (tester) async { - await tester.pumpWidget(buildSut()); - final formCubit = BlocProvider.of( - tester.element(find.byType(ValueCorrectionPage)), - ); + testWidgets('o texto de apoio mostra a duração', (tester) async { + await pump(tester); + await fillExample(tester); - formCubit.setIndex(CorrectionIndex.ipcaE); - formCubit.initialDate.text = '12/1991'; - formCubit.finalDate.text = '01/1992'; - await tester.pump(); + expect( + find.text('25 meses. O IPCA tem dados desde 01/1980.'), + findsOneWidget, + ); + }); - expect( - find.text('O índice IPCA-E (IBGE) possui dados a partir de 01/1992.'), - findsOneWidget, - ); + testWidgets('IPCA, 12/2001 a 12/2003, R\$ 5.555,55: valor corrigido, ' + 'correção, fator e barra 81/19', (tester) async { + await pump(tester); + await fillExample(tester); + await correct(tester); + + expect(inBand('Hoje, seus R\$\u00A05.555,55 valem'), findsOneWidget); + expect(inBand('6.877,50'), findsOneWidget); + expect(inBand('+23,80% pelo IPCA, de 12/2001 a 12/2003'), findsOneWidget); + expect(find.text('Do valor corrigido'), findsOneWidget); + expect(find.textContaining('1.321,95'), findsOneWidget); + expect(find.text('1,2379507'), findsOneWidget); + expect(find.text('Valor original 81%'), findsOneWidget); + expect(find.text('Correção 19%'), findsOneWidget); + }); - formCubit.setIndex(CorrectionIndex.selic); - formCubit.initialDate.text = '03/06/1986'; - formCubit.finalDate.text = '04/06/1986'; - await tester.pump(); + testWidgets('sem valor, mostra só a variação e o fator', (tester) async { + calculationReturns(ipcaResult(withValue: false)); + await pump(tester); + await fillExample(tester, withValue: false); + await correct(tester); - expect( - find.text('O índice Selic possui dados a partir de 04/06/1986.'), - findsOneWidget, - ); - }, - ); + expect(inBand('+23,80%'), findsOneWidget); + expect(inBand('Fator de correção 1,2379507'), findsOneWidget); + expect(find.text('Variação'), findsOneWidget); + expect(find.text('Valor original 81%'), findsNothing); + }); - testWidgets( - 'o botão Corrigir valor só chama o cubit quando o formulário é válido', - (tester) async { - final formCubit = buildFormCubit()..setIndex(CorrectionIndex.ipca); - final cubit = buildValueCorrectionCubit( - seriesRepository, - calculateValueCorrection, - ); + testWidgets('mudar um campo depois do cálculo volta ao estado inicial', ( + tester, + ) async { + await pump(tester); + await fillExample(tester); + await correct(tester); + expect(find.byType(ResultDetails), findsOneWidget); - when( - () => seriesRepository.getSeries( - index: any(named: 'index'), - start: any(named: 'start'), - end: any(named: 'end'), - ), - ).thenAnswer((_) async { - return SuccessResult>([ - SeriesPoint(date: DateTime(2024, 1, 1), value: 1.5), - ]); - }); - - when( - () => calculateValueCorrection.call( - params: any(named: 'params'), - series: any(named: 'series'), - type: any(named: 'type'), - ), - ).thenReturn( - SuccessResult( - ValueCorrection( - index: CorrectionIndex.ipca.sgsCode, - period: Period( - initial: DateTime(2024, 1, 1), - end: DateTime(2024, 12, 1), - ), - percentage: 0, - originalValue: 100, - factor: 1.015, - adjustedValue: 101.5, - variation: 1.5, - ), - ), - ); + formCubit.finalDate.text = '11/2003'; + await tester.pump(); - await tester.pumpWidget( - MultiBlocProvider( - providers: [ - BlocProvider.value(value: formCubit), - BlocProvider.value(value: cubit), - ], - child: MaterialApp( - theme: AppTheme.darkTheme, - home: const ValueCorrectionPage(), - ), - ), - ); + expect(inBand('o seu dinheiro?'), findsOneWidget); + expect(find.byType(ResultDetails), findsNothing); + }); - final calculateButton = find.widgetWithText( - ElevatedButton, - 'Corrigir valor', - ); - expect( - tester.widget(calculateButton).onPressed, - isNull, - ); + testWidgets('carregando: indicador no botão e campos bloqueados', ( + tester, + ) async { + final pending = Completer>>(); + seriesAnswers(() => pending.future); + await pump(tester); + await fillExample(tester); - formCubit.initialDate.text = '01/2024'; - formCubit.finalDate.text = '12/2024'; - formCubit.value.text = '100'; - await tester.pump(); + await tester.tap(find.text('Corrigir valor')); + await tester.pump(); - expect( - tester.widget(calculateButton).onPressed, - isNotNull, - ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + final absorb = tester.widget( + find + .ancestor( + of: row(ValueCorrectionField.initialDate), + matching: find.byType(AbsorbPointer), + ) + .first, + ); + expect(absorb.absorbing, isTrue); - await tester.tap(calculateButton); - await tester.pumpAndSettle(); - - verify( - () => seriesRepository.getSeries( - index: CorrectionIndex.ipca, - start: DateTime(2024, 1, 1), - end: DateTime(2024, 12, 1), - ), - ).called(1); - - final result = - verify( - () => navigator.pushNamed( - AppRoutes.correctionResult, - arguments: captureAny(named: 'arguments'), - ), - ).captured.single - as ValueCorrection; - expect(result.index, CorrectionIndex.ipca.sgsCode); - expect(result.adjustedValue, 101.5); - }, - ); + pending.complete( + SuccessResult>(const []), + ); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); - testWidgets('tenta novamente após falha de rede com texto branco', ( + testWidgets('data fora da série: linha em erro, aviso e atalho', ( tester, ) async { - final formCubit = buildFormCubit()..setIndex(CorrectionIndex.ipca); - final cubit = buildValueCorrectionCubit( - seriesRepository, - calculateValueCorrection, - ); - var requestCount = 0; - - when( - () => seriesRepository.getSeries( - index: any(named: 'index'), - start: any(named: 'start'), - end: any(named: 'end'), - ), - ).thenAnswer((_) async { - requestCount++; - if (requestCount == 1) { - return FailureResult>( - ServerErrorMessages.connectionError, - ); - } - return SuccessResult>([ - SeriesPoint(date: DateTime(2024, 1, 1), value: 1.5), - ]); - }); + await pump(tester); + formCubit.initialDate.text = '12/1979'; + formCubit.finalDate.text = '12/1981'; + await tester.pump(); - when( - () => calculateValueCorrection.call( - params: any(named: 'params'), - series: any(named: 'series'), - type: any(named: 'type'), - ), - ).thenReturn( - SuccessResult( - ValueCorrection( - index: CorrectionIndex.ipca.sgsCode, - period: Period( - initial: DateTime(2024, 1, 1), - end: DateTime(2024, 1, 1), - ), - percentage: 0, - originalValue: 100, - factor: 1.015, - adjustedValue: 101.5, - variation: 1.5, - ), + expect( + find.descendant( + of: row(ValueCorrectionField.initialDate), + matching: find.text(ValueCorrectionPage.outOfRangeText), ), + findsOneWidget, ); - - await tester.pumpWidget( - MultiBlocProvider( - providers: [ - BlocProvider.value(value: formCubit), - BlocProvider.value(value: cubit), - ], - child: MaterialApp( - theme: AppTheme.darkTheme, - home: const ValueCorrectionPage(), - ), - ), + expect(find.text('Data fora do período'), findsOneWidget); + expect(inBand('ajuste a data'), findsOneWidget); + expect(find.text('O IPCA tem dados desde 01/1980.'), findsOneWidget); + expect( + find.text('O IPCA tem dados a partir de 01/1980.'), + findsNWidgets(2), ); - formCubit.initialDate.text = '01/2024'; - formCubit.finalDate.text = '01/2024'; + await tester.tap(find.text('Usar 01/1980')); await tester.pump(); - await tester.tap(find.text('Corrigir valor')); - await tester.pumpAndSettle(); - final retryButton = find.widgetWithText(TextButton, 'Tentar novamente'); - expect(retryButton, findsOneWidget); - final button = tester.widget(retryButton); - expect(button.onPressed, isNotNull); - expect(button.style?.foregroundColor?.resolve({}), Colors.white); + expect(formCubit.initialDate.text, '01/1980'); + expect(find.text('Data fora do período'), findsNothing); + }); - await tester.tap(retryButton); + testWidgets('erro de rede: faixa "Sem conexão" e "Tentar de novo"', ( + tester, + ) async { + seriesAnswers( + () async => FailureResult>( + ServerErrorMessages.connectionError, + ), + ); + await pump(tester); + await fillExample(tester); + await correct(tester); + + expect(inBand('Banco Central'), findsOneWidget); + expect(find.text('Sem conexão'), findsOneWidget); + + await tester.tap(find.text('Tentar de novo')); await tester.pumpAndSettle(); - expect(requestCount, 2); verify( - () => navigator.pushNamed( - AppRoutes.correctionResult, - arguments: any(named: 'arguments'), + () => repository.getSeries( + index: any(named: 'index'), + start: any(named: 'start'), + end: any(named: 'end'), ), - ).called(1); + ).called(2); }); - ValueCorrection buildResult({ - bool withAdjustedValue = true, - int index = 433, - double factor = 1.1434, - double? originalValue = 5000, - double? adjustedValue = 5717, - double variation = 14.34, - }) { - return ValueCorrection( - index: index, - period: Period( - initial: DateTime(2023, 1, 1), - end: DateTime(2024, 12, 1), - ), - percentage: 0, - originalValue: withAdjustedValue ? originalValue : null, - factor: factor, - adjustedValue: withAdjustedValue ? adjustedValue : null, - variation: variation, - ); - } + testWidgets('compartilhar usa o texto da Correção', (tester) async { + await pump(tester); + await fillExample(tester); - testWidgets('exibe o resultado com valor corrigido e fator em pt-BR', ( - tester, - ) async { - final result = buildResult(); + final share = find.byType(OutlinedButton); + expect(tester.widget(share).onPressed, isNull); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: ValueCorrectionResultPage(result: result), - ), - ); + await correct(tester); + await tester.tap(share); - expect(find.textContaining('5.717,00'), findsOneWidget); - expect(find.textContaining('+14,34%'), findsOneWidget); - expect(find.textContaining('1,1434000'), findsOneWidget); + expect( + sharer.shared.single, + startsWith('RealCalc · Correção de Valores'), + ); }); - testWidgets('mostra só o fator e o índice quando não há valor', ( - tester, - ) async { - final result = buildResult( - withAdjustedValue: false, - originalValue: null, - adjustedValue: null, - ); + testWidgets('no CDI mostra o percentual e datas com dia', (tester) async { + await pump(tester); + formCubit.setIndex(CorrectionIndex.cdi); + await tester.pump(); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: ValueCorrectionResultPage(result: result), + expect(find.text('Percentual'), findsOneWidget); + expect(find.text('% do CDI'), findsOneWidget); + expect(find.text('CDI'), findsOneWidget); + expect( + find.descendant( + of: row(ValueCorrectionField.initialDate), + matching: find.text('01/01/2023'), ), + findsOneWidget, ); + }); + + testWidgets('limpar e desfazer', (tester) async { + await pump(tester); + await fillExample(tester); + await correct(tester); + + await tester.tap(find.bySemanticsLabel(CalculatorBand.clearLabel)); + await tester.pumpAndSettle(); + expect(formCubit.value.text, isEmpty); + expect(find.byType(ResultDetails), findsNothing); - expect(find.text('Valor corrigido'), findsNothing); - expect(find.text('Valor original'), findsNothing); - expect(find.textContaining('1,1434000'), findsNWidgets(2)); - expect(find.textContaining('IPCA'), findsNWidgets(2)); + await tester.tap(find.text('Desfazer')); + await tester.pumpAndSettle(); + expect(formCubit.value.text, '5.555,55'); + expect(inBand('6.877,50'), findsOneWidget); }); - testWidgets('exibe ação para editar os dados do cálculo', (tester) async { - final result = buildResult(); + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('em 360dp e fonte em 130% no tema ${themeMode.name}, a ' + 'barra não cobre o último item', (tester) async { + await pump( + tester, + themeMode: themeMode, + size: const Size(360, 700), + textScale: 1.3, + ); + await fillExample(tester); + await correct(tester); + + expect(tester.takeException(), isNull); + + final last = find.text('1,2379507'); + await tester.scrollUntilVisible( + last, + 100, + scrollable: find.byType(Scrollable).first, + ); + final bar = tester.getRect(find.byType(BottomActionBar)); + expect(tester.getRect(last).bottom, lessThanOrEqualTo(bar.top)); + }); + } + + testWidgets('o teclado não cobre o campo de valor', (tester) async { + await pump(tester, size: const Size(360, 700)); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: ValueCorrectionResultPage(result: result), + await tester.showKeyboard( + find.descendant( + of: row(ValueCorrectionField.value), + matching: find.byType(TextField), ), ); + tester.view.viewInsets = const FakeViewPadding(bottom: 320); + await tester.pumpAndSettle(); - expect(find.text('Editar Dados'), findsOneWidget); + final field = tester.getRect(row(ValueCorrectionField.value)); + final bar = tester.getRect(find.byType(BottomActionBar)); + expect(field.bottom, lessThanOrEqualTo(bar.top)); }); - testWidgets('não extrapola o layout em largura de 360px', (tester) async { - final result = ValueCorrection( - index: 433, - period: Period( - initial: DateTime(2023, 1, 1), - end: DateTime(2024, 12, 1), - ), - percentage: 0, - originalValue: 5000, - factor: 1.1434, - adjustedValue: 5717, - variation: 14.34, - ); + testWidgets('a linha Índice abre o seletor', (tester) async { + await pump(tester); - tester.view.physicalSize = const Size(360, 800); - tester.view.devicePixelRatio = 1; + await tester.tap(find.text('Índice')); + await tester.pumpAndSettle(); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: ValueCorrectionResultPage(result: result), - ), - ); + expect(find.text('INFLAÇÃO'), findsOneWidget); - expect(tester.takeException(), isNull); + await tester.tap(find.text('IGP-M')); + await tester.pumpAndSettle(); + expect(formCubit.state.index, CorrectionIndex.igpM); }); }); } diff --git a/test/modules/value_correction/presentation/utils/correction_result_summary_test.dart b/test/modules/value_correction/presentation/utils/correction_result_summary_test.dart new file mode 100644 index 0000000..77d5fe5 --- /dev/null +++ b/test/modules/value_correction/presentation/utils/correction_result_summary_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/result_details/result_summary.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/period.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; +import 'package:real_calc/modules/value_correction/presentation/utils/correction_result_summary.dart'; + +ValueCorrection correctionOf({ + double? original = 1000, + double? adjusted = 1234.5678, + double factor = 1.2345678, +}) => ValueCorrection( + index: 433, + period: Period(initial: DateTime(2022), end: DateTime(2025)), + percentage: 100, + originalValue: original, + factor: factor, + adjustedValue: adjusted, + variation: (factor - 1) * 100, +); + +void main() { + group('correctionResultSummary', () { + test('valor original 81%, correção 19%', () { + final summary = correctionResultSummary(correctionOf()); + + expect(summary.title, 'Do valor corrigido'); + expect( + summary.composition!.own, + const ResultSegment('Valor original', 81), + ); + expect(summary.composition!.other, const ResultSegment('Correção', 19)); + }); + + test('a correção sai dos valores arredondados', () { + final summary = correctionResultSummary(correctionOf()); + + expect(summary.lines, const [ + ResultDetailLine('Valor original', 'R\$\u00A01.000,00'), + ResultDetailLine('Correção no período', 'R\$\u00A0234,57'), + ResultDetailLine('Fator de correção', '1,2345678'), + ]); + }); + + test('com deflação, mostra as linhas mas omite a barra', () { + final summary = correctionResultSummary( + correctionOf(adjusted: 990, factor: 0.99), + ); + + expect(summary.composition, isNull); + expect(summary.lines[1].value, '-R\$\u00A010,00'); + }); + + test('sem valor original, mostra só a variação e o fator', () { + final summary = correctionResultSummary( + correctionOf(original: null, adjusted: null), + ); + + expect(summary.title, 'Da correção no período'); + expect(summary.composition, isNull); + expect(summary.lines, const [ + ResultDetailLine('Variação', '+23,46%'), + ResultDetailLine('Fator de correção', '1,2345678'), + ]); + }); + }); +} diff --git a/test/modules/value_correction/presentation/utils/correction_statement_test.dart b/test/modules/value_correction/presentation/utils/correction_statement_test.dart new file mode 100644 index 0000000..8003d50 --- /dev/null +++ b/test/modules/value_correction/presentation/utils/correction_statement_test.dart @@ -0,0 +1,137 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/calculator_band/band_statement.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/period.dart'; +import 'package:real_calc/modules/value_correction/domain/entites/value_correction.dart'; +import 'package:real_calc/modules/value_correction/domain/enum/correction_index.dart'; +import 'package:real_calc/modules/value_correction/presentation/utils/correction_statement.dart'; + +ValueCorrection ipcaResult({double? original = 5555.55, double? adjusted}) => + ValueCorrection( + index: CorrectionIndex.ipca.sgsCode, + period: Period(initial: DateTime(2001, 12), end: DateTime(2003, 12)), + percentage: 100, + originalValue: original, + factor: 1.2379507, + adjustedValue: original == null ? null : (adjusted ?? 6877.498), + variation: 23.79507, + ); + +void main() { + final nbsp = String.fromCharCode(0xA0); + + group('correctionStatement', () { + test('inicial', () { + expect( + correctionStatement(index: CorrectionIndex.ipca), + const BandStatement( + kicker: 'Quanto vale hoje', + value: 'o seu dinheiro?', + sub: 'Escolha o índice, o período e o valor.', + ), + ); + }); + + test('com valor', () { + expect( + correctionStatement(index: CorrectionIndex.ipca, result: ipcaResult()), + BandStatement( + kicker: 'Hoje, seus R\$${nbsp}5.555,55 valem', + value: '6.877,50', + isMoney: true, + sub: '+23,80% pelo IPCA, de 12/2001 a 12/2003', + ), + ); + }); + + test('sem valor', () { + expect( + correctionStatement( + index: CorrectionIndex.ipca, + result: ipcaResult(original: null), + ), + const BandStatement( + kicker: 'Pelo IPCA, de 12/2001 a 12/2003, a correção é de', + value: '+23,80%', + sub: 'Fator de correção 1,2379507', + ), + ); + }); + + test('data fora da série', () { + expect( + correctionStatement(index: CorrectionIndex.ipca, outOfRange: true), + const BandStatement( + kicker: 'Para corrigir pelo IPCA,', + value: 'ajuste a data', + sub: 'O IPCA tem dados a partir de 01/1980.', + ), + ); + }); + + test('sem conexão tem prioridade', () { + expect( + correctionStatement( + index: CorrectionIndex.ipca, + result: ipcaResult(), + outOfRange: true, + offline: true, + ), + const BandStatement( + kicker: 'Sem conexão com o', + value: 'Banco Central', + sub: 'Confira a internet e tente de novo.', + ), + ); + }); + + test('usa o artigo de cada índice e a data diária', () { + expect( + correctionStatement( + index: CorrectionIndex.selic, + outOfRange: true, + ).kicker, + 'Para corrigir pela Selic,', + ); + expect( + CorrectionIndex.poupancaNova.availabilitySentence, + 'A Poupança Nova tem dados a partir de 05/2012.', + ); + expect( + CorrectionIndex.igpM.sinceSentence, + 'O IGP-M tem dados desde 06/1989.', + ); + }); + }); + + group('correctionDuration', () { + test('conta os meses incluindo o início e o fim', () { + expect( + correctionDuration( + CorrectionIndex.ipca, + DateTime(2001, 12), + DateTime(2003, 12), + ), + '25 meses', + ); + expect( + correctionDuration( + CorrectionIndex.ipca, + DateTime(2024, 3), + DateTime(2024, 3), + ), + '1 mês', + ); + }); + + test('conta dias nos índices diários', () { + expect( + correctionDuration( + CorrectionIndex.cdi, + DateTime(2024, 1, 1), + DateTime(2024, 1, 31), + ), + '30 dias', + ); + }); + }); +} diff --git a/test/modules/value_correction/presentation/utils/value_correction_share_text_test.dart b/test/modules/value_correction/presentation/utils/value_correction_share_text_test.dart index 491ec68..917beef 100644 --- a/test/modules/value_correction/presentation/utils/value_correction_share_text_test.dart +++ b/test/modules/value_correction/presentation/utils/value_correction_share_text_test.dart @@ -39,7 +39,7 @@ void main() { expect( buildShareText(result()), 'RealCalc · Correção de Valores\n' - 'Índice: IPCA (IBGE)\n' + 'Índice: IPCA\n' 'Período: 01/2023 a 12/2025\n' 'Valor original: R\$ 5.000,00\n' 'Fator de correção: 1,1434000\n' @@ -56,7 +56,7 @@ void main() { expect( text, 'RealCalc · Correção de Valores\n' - 'Índice: IPCA (IBGE)\n' + 'Índice: IPCA\n' 'Período: 01/2023 a 12/2025\n' 'Fator de correção: 1,1434000\n\n' 'Simulação feita no RealCalc, sem valor oficial.', diff --git a/test/modules/value_correction/presentation/widgets/correction_index_picker_test.dart b/test/modules/value_correction/presentation/widgets/correction_index_picker_test.dart index dd8bb6f..f28f231 100644 --- a/test/modules/value_correction/presentation/widgets/correction_index_picker_test.dart +++ b/test/modules/value_correction/presentation/widgets/correction_index_picker_test.dart @@ -1,223 +1,249 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:real_calc/core/widgets/app_icon.dart'; + +import '../../../../app_icon_finder.dart'; import 'package:real_calc/core/themes/app_theme.dart'; +import 'package:real_calc/core/themes/calculator_palette.dart'; +import 'package:real_calc/core/widgets/app_sheet.dart'; import 'package:real_calc/modules/value_correction/domain/enum/correction_index.dart'; import 'package:real_calc/modules/value_correction/presentation/widgets/correction_index_picker.dart'; void main() { - Future openPicker(WidgetTester tester) async { - await tester.tap(find.text('Abrir seletor')); + CorrectionIndex? selected; + + Future open( + WidgetTester tester, { + Size size = const Size(400, 800), + ThemeData? theme, + }) async { + selected = null; + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: theme ?? AppTheme.lightTheme, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showCorrectionIndexPicker( + context, + selectedIndex: CorrectionIndex.ipca, + onSelected: (index) => selected = index, + ), + child: const Text('Abrir'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Abrir')); await tester.pumpAndSettle(); } - group('CorrectionIndexPicker', () { - testWidgets('renderiza os 13 índices em 3 grupos', (tester) async { - tester.view.physicalSize = const Size(800, 1000); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); + Finder row(CorrectionIndex index) => find.byKey(ValueKey(index.name)); + Finder tab(String label) => find.byKey(ValueKey('tab-$label')); + final search = find.byType(TextField); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) { - return CorrectionIndexPicker( - selectedIndex: CorrectionIndex.ipca, - indices: CorrectionIndex.values, - ); - }, - ); - }, - child: const Text('Abrir seletor'), - ), - ), - ); - }, - ), + Future scrollTo(WidgetTester tester, Finder finder) => + tester.scrollUntilVisible( + finder, + 200, + scrollable: find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), ), ); - await openPicker(tester); - + group('CorrectionIndexPicker', () { + testWidgets('abre sobre o AppSheet com título, busca e abas', ( + tester, + ) async { + await open(tester); + + expect(find.byType(AppSheet), findsOneWidget); + expect(find.text(correctionIndexPickerTitle), findsOneWidget); + expect(find.text(correctionIndexSearchHint), findsOneWidget); + for (final label in ['Todos', 'Inflação', 'Juros', 'Poupança']) { + expect(tab(label), findsOneWidget); + } expect(find.text('INFLAÇÃO'), findsOneWidget); + }); - await tester.scrollUntilVisible( - find.text('Poupança Nova'), - 200, - scrollable: find.byType(Scrollable), - ); - await tester.pumpAndSettle(); + testWidgets('mostra fonte e "desde" vindos do enum', (tester) async { + await open(tester); - expect(find.text('Poupança Nova'), findsOneWidget); - expect(find.text('Poupança Velha'), findsOneWidget); + expect(find.text('IPCA'), findsOneWidget); + expect(find.text('IBGE, desde 01/1980'), findsOneWidget); + + await scrollTo(tester, row(CorrectionIndex.selic)); + final selic = CorrectionIndex.selic; + expect( + find.text('${selic.source}, desde ${selic.sinceMonthLabel}'), + findsOneWidget, + ); + expect(find.text('Banco Central, desde 06/1986'), findsOneWidget); }); - testWidgets('marca o índice selecionado e chama onSelected', (tester) async { - CorrectionIndex? selected; - tester.view.physicalSize = const Size(800, 1000); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); + testWidgets('o índice atual fica em negrito e com ✓', (tester) async { + await open(tester); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) { - return CorrectionIndexPicker( - selectedIndex: CorrectionIndex.ipca, - indices: CorrectionIndex.values, - onSelected: (index) => selected = index, - ); - }, - ); - }, - child: const Text('Abrir seletor'), - ), - ), - ); - }, - ), + expect(findAppIcon(AppIcons.check), findsOneWidget); + expect( + find.descendant( + of: row(CorrectionIndex.ipca), + matching: findAppIcon(AppIcons.check), ), + findsOneWidget, ); + final ipca = tester.widget(find.text('IPCA')); + final inpc = tester.widget(find.text('INPC')); + expect(ipca.style!.fontWeight, FontWeight.w800); + expect(inpc.style!.fontWeight, FontWeight.w500); + }); - await openPicker(tester); - - expect(find.byIcon(Icons.check), findsOneWidget); + testWidgets('escolher um índice chama onSelected e fecha', (tester) async { + await open(tester); - await tester.scrollUntilVisible( - find.text(CorrectionIndex.selic.label), - 200, - scrollable: find.byType(Scrollable), - ); - await tester.tap(find.text(CorrectionIndex.selic.label)); + await scrollTo(tester, row(CorrectionIndex.selic)); + await tester.tap(row(CorrectionIndex.selic)); await tester.pumpAndSettle(); expect(selected, CorrectionIndex.selic); + expect(find.byType(CorrectionIndexPicker), findsNothing); }); - testWidgets('fecha ao selecionar um índice', (tester) async { - CorrectionIndex? selected; - tester.view.physicalSize = const Size(800, 1000); - tester.view.devicePixelRatio = 1; - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); + testWidgets('a busca ignora acentos e maiúsculas', (tester) async { + await open(tester); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) { - return CorrectionIndexPicker( - selectedIndex: CorrectionIndex.ipca, - indices: CorrectionIndex.values, - onSelected: (index) => selected = index, - ); - }, - ); - }, - child: const Text('Abrir seletor'), - ), - ), - ); - }, - ), - ), - ); + await tester.enterText(search, 'POUPANCA'); + await tester.pumpAndSettle(); + expect(row(CorrectionIndex.poupancaNova), findsOneWidget); + expect(row(CorrectionIndex.poupancaVelha), findsOneWidget); + expect(row(CorrectionIndex.ipca), findsNothing); - await openPicker(tester); + await tester.enterText(search, 'ipç'); + await tester.pumpAndSettle(); + expect(row(CorrectionIndex.ipca), findsOneWidget); + expect(row(CorrectionIndex.ipcSp), findsOneWidget); + expect(row(CorrectionIndex.cdi), findsNothing); + }); - expect(find.byType(CorrectionIndexPicker), findsOneWidget); + testWidgets('a busca também encontra pela fonte', (tester) async { + await open(tester); - await tester.scrollUntilVisible( - find.text(CorrectionIndex.selic.label), - 200, - scrollable: find.byType(Scrollable), - ); - await tester.tap(find.text(CorrectionIndex.selic.label)); + await tester.enterText(search, 'fgv'); await tester.pumpAndSettle(); - expect(selected, CorrectionIndex.selic); - expect(find.byType(CorrectionIndexPicker), findsNothing); + expect(row(CorrectionIndex.igpDi), findsOneWidget); + expect(row(CorrectionIndex.igpM), findsOneWidget); + expect(row(CorrectionIndex.ipcBrasil), findsOneWidget); + expect(row(CorrectionIndex.ipca), findsNothing); }); - testWidgets('não há overflow em 360x640', (tester) async { - tester.view.physicalSize = const Size(360, 640); - tester.view.devicePixelRatio = 1; + testWidgets('sem resultado, avisa e limpar volta a lista', (tester) async { + await open(tester); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); + await tester.enterText(search, 'xyz'); + await tester.pumpAndSettle(); + expect(find.text(correctionIndexNoResults), findsOneWidget); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.darkTheme, - home: Builder( - builder: (context) { - return Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) { - return CorrectionIndexPicker( - selectedIndex: CorrectionIndex.ipca, - indices: CorrectionIndex.values, - ); - }, - ); - }, - child: const Text('Abrir seletor'), - ), - ), - ); - }, - ), - ), - ); + await tester.tap(find.byTooltip('Limpar busca')); + await tester.pumpAndSettle(); + expect(find.text(correctionIndexNoResults), findsNothing); + expect(row(CorrectionIndex.ipca), findsOneWidget); + }); + + testWidgets('as abas filtram pelo grupo', (tester) async { + await open(tester); + + await tester.tap(tab('Juros')); + await tester.pumpAndSettle(); + for (final index in [ + CorrectionIndex.cdi, + CorrectionIndex.selic, + CorrectionIndex.tr, + CorrectionIndex.taxaLegal, + ]) { + expect(row(index), findsOneWidget); + } + expect(row(CorrectionIndex.ipca), findsNothing); + expect(row(CorrectionIndex.poupancaNova), findsNothing); + expect(find.text('JUROS'), findsNothing); + + // A fonte de teste é larga; as abas rolam na horizontal. + await tester.ensureVisible(tab('Poupança')); + await tester.tap(tab('Poupança')); + await tester.pumpAndSettle(); + expect(row(CorrectionIndex.poupancaNova), findsOneWidget); + expect(row(CorrectionIndex.cdi), findsNothing); - await tester.tap(find.text('Abrir seletor')); + await tester.ensureVisible(tab('Todos')); + await tester.tap(tab('Todos')); + await tester.pumpAndSettle(); + expect(row(CorrectionIndex.ipca), findsOneWidget); + }); + + testWidgets('a aba ativa fica sublinhada na cor da Correção', ( + tester, + ) async { + await open(tester); + await tester.tap(tab('Juros')); + await tester.pumpAndSettle(); + + Color underline(String label) { + final box = tester.widget( + find.descendant(of: tab(label), matching: find.byType(Container)), + ); + final border = (box.decoration! as BoxDecoration).border! as Border; + return border.bottom.color; + } + + final palette = CalculatorPalette.of(CalculatorKind.correction); + expect(underline('Juros'), palette.text); + expect(underline('Todos'), Colors.transparent); + }); + + testWidgets('busca e aba se combinam', (tester) async { + await open(tester); + + await tester.tap(tab('Inflação')); + await tester.enterText(search, 'selic'); await tester.pumpAndSettle(); + expect(find.text(correctionIndexNoResults), findsOneWidget); + }); + + testWidgets('com a busca em foco a folha sobe e a lista fica acima do ' + 'teclado', (tester) async { + await open(tester, size: const Size(360, 640)); + + await tester.showKeyboard(search); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pumpAndSettle(); + + final sheet = tester.getRect(find.byType(AppSheet)); + expect(sheet.top, AppSheet.topGap); + + final list = tester.getRect(find.byType(ListView)); + expect(list.bottom, lessThanOrEqualTo(640 - 300)); + expect(list.height, greaterThan(0)); expect(tester.takeException(), isNull); }); + + for (final (name, theme) in [ + ('claro', AppTheme.lightTheme), + ('escuro', AppTheme.darkTheme), + ]) { + testWidgets('sem overflow em 360x640 no tema $name', (tester) async { + await open(tester, size: const Size(360, 640), theme: theme); + expect(tester.takeException(), isNull); + }); + } }); } diff --git a/test/modules/value_correction/use_cases/calculate_value_correction_test.dart b/test/modules/value_correction/use_cases/calculate_value_correction_test.dart index c037e04..dca14d4 100644 --- a/test/modules/value_correction/use_cases/calculate_value_correction_test.dart +++ b/test/modules/value_correction/use_cases/calculate_value_correction_test.dart @@ -156,6 +156,54 @@ void main() { expect(result.getOrNull()!.factor, 1); }); + test( + 'case de referência: IGP-M de 01/2015 a 12/2020 reproduz a Calculadora do Cidadão', + () { + const monthlyRates = ''' + 0.7647618382, 0.2705864365, 0.9808441075, 1.1656857512, 0.4063001692, 0.6715458172, + 0.6911045826, 0.2755675908, 0.9461909183, 1.8911533913, 1.5242249087, 0.4874187975, + 1.1370339879, 1.2905810339, 0.5117747748, 0.3281660945, 0.8184376735, 1.6875411382, + 0.1752114780, 0.1467980160, 0.1970707838, 0.1572552040, -0.0265986956, 0.5400211630, + 0.6408550379, 0.0836911870, 0.0147125277, -1.0956218656, -0.9318589524, -0.6662499732, + -0.7160603878, 0.0958422781, 0.4681998128, 0.1964782206, 0.5243019585, 0.8873239653, + 0.7550250130, 0.0731715412, 0.6351470125, 0.5697173435, 1.3781659311, 1.8672336349, + 0.5067165546, 0.6999138921, 1.5243480975, 0.8859122959, -0.4895058190, -1.0801688000, + 0.0066436636, 0.8846793161, 1.2553467825, 0.9186295414, 0.4454675833, 0.7952552229, + 0.3961154951, -0.6668950800, -0.0054318158, 0.6750755742, 0.3001350270, 2.0918341273, + 0.4770047108, -0.0406433182, 1.2440600559, 0.8022976831, 0.2800407659, 1.5570051776, + 2.2278336608, 2.7441632524, 4.3407948692, 3.2314190240, 3.2773938796, 0.9581082789 + '''; + final rates = monthlyRates + .split(',') + .map((value) => double.parse(value.trim())) + .toList(); + final series = List.generate( + rates.length, + (index) => SeriesPoint( + date: DateTime(2015, index + 1, 1), + value: rates[index], + ), + ); + + final result = + CalculateValueCorrection( + buildDataPreparer(), + buildRateCalculator(), + ).call( + params: buildValueCorrection( + index: CorrectionIndex.igpM.sgsCode, + initial: DateTime(2015, 1, 1), + end: DateTime(2020, 12, 1), + ), + series: series, + type: SeriesKind.monthlyVariation, + ); + + expect(result.isSuccess, isTrue); + expect(result.getOrNull()!.factor, closeTo(1.67455430, 1e-7)); + }, + ); + test( 'case de referência: IPCA de 01/2003 a 01/2003 produz fator 1,0225', () { diff --git a/test/modules/value_correction/use_cases/correction/series_selection/correction_index_series_selector_test.dart b/test/modules/value_correction/use_cases/correction/series_selection/correction_index_series_selector_test.dart index 033b4c0..3883a1a 100644 --- a/test/modules/value_correction/use_cases/correction/series_selection/correction_index_series_selector_test.dart +++ b/test/modules/value_correction/use_cases/correction/series_selection/correction_index_series_selector_test.dart @@ -17,6 +17,29 @@ void main() { }); group('CorrectionIndexSeriesSelector', () { + test('IGP-M usa a série mensal histórica desde 06/1989', () { + final igpMSeries = [ + SeriesPoint(date: DateTime(2015, 1, 1), value: 0.76), + SeriesPoint(date: DateTime(2020, 12, 1), value: 0.96), + ]; + + expect(CorrectionIndex.igpM.sgsCode, 28655); + expect(CorrectionIndex.igpM.fromYear, 1989); + expect(CorrectionIndex.igpM.fromMonth, 6); + expect(CorrectionIndex.igpM.fromDay, 1); + expect(CorrectionIndex.igpM.availableFromInputLabel, '06/1989'); + expect( + selector.selectCalculationSeries( + index: CorrectionIndex.igpM.sgsCode, + type: SeriesKind.monthlyVariation, + filteredSeries: igpMSeries, + savingsRates: const [], + trRates: const [], + ), + igpMSeries, + ); + }); + test('calcula taxa legal apenas para o índice Taxa Legal', () { final series = [SeriesPoint(date: DateTime(2024, 1, 1), value: 1)];