├── .gitignore ├── .metadata ├── .vscode └── launch.json ├── COVER_LETTER_1.md ├── COVER_LETTER_2.md ├── LICENSE ├── README.md ├── RESUME.pdf ├── analysis_options.yaml ├── android ├── .gitignore ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ └── AndroidManifest.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── flutter_firebase_login │ │ │ │ └── MainActivity.kt │ │ └── res │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ │ └── values │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties └── settings.gradle ├── assets ├── bloc_logo_small.png └── swing_project.png ├── build.yaml ├── ios ├── .gitignore ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-App-1024x1024@1x.png │ │ ├── Icon-App-20x20@1x.png │ │ ├── Icon-App-20x20@2x.png │ │ ├── Icon-App-20x20@3x.png │ │ ├── Icon-App-29x29@1x.png │ │ ├── Icon-App-29x29@2x.png │ │ ├── Icon-App-29x29@3x.png │ │ ├── Icon-App-40x40@1x.png │ │ ├── Icon-App-40x40@2x.png │ │ ├── Icon-App-40x40@3x.png │ │ ├── Icon-App-60x60@2x.png │ │ ├── Icon-App-60x60@3x.png │ │ ├── Icon-App-76x76@1x.png │ │ ├── Icon-App-76x76@2x.png │ │ └── Icon-App-83.5x83.5@2x.png │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ ├── LaunchImage.png │ │ ├── LaunchImage@2x.png │ │ ├── LaunchImage@3x.png │ │ └── README.md │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── Info.plist │ └── Runner-Bridging-Header.h ├── lib ├── common │ ├── cache_client.dart │ ├── const.dart │ ├── graphql_service.dart │ ├── helpers.dart │ ├── route.dart │ ├── simple_bloc_observer.dart │ └── theme.dart ├── cubits │ ├── authentication.dart │ ├── github_repositories.dart │ ├── login.dart │ ├── sign_up.dart │ └── todos.dart ├── import.dart ├── main.dart ├── models │ ├── confirmed_password_input.dart │ ├── email_input.dart │ ├── password_input.dart │ ├── repository.dart │ ├── todo.dart │ └── user.dart ├── repositories │ ├── authentication.dart │ ├── database.dart │ ├── database_api.dart │ ├── github.dart │ ├── github_api.dart │ └── storage.dart ├── screens │ ├── github_repositories.dart │ ├── home.dart │ ├── login.dart │ ├── sign_up.dart │ ├── splash.dart │ └── todos.dart └── widgets │ └── avatar.dart ├── pubspec.lock ├── pubspec.yaml ├── test ├── common │ └── cache_client_test.dart ├── cubits │ ├── authentication_test.dart │ ├── login_test.dart │ └── sign_up_test.dart ├── main_test.dart ├── models │ ├── confirmed_password_test.dart │ ├── email_test.dart │ ├── password_test.dart │ └── user_test.dart ├── repositories │ └── authentication_test.dart ├── screens │ ├── home_test.dart │ ├── login_test.dart │ ├── sign_up_test.dart │ └── splash_test.dart └── widgets │ └── avatar_test.dart └── test_driver ├── app.dart ├── features └── login.feature ├── hook_example.dart ├── main_test.dart ├── step.dart └── step_definitions.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | output.mp4 12 | coverage/ 13 | lib/local.dart 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins 31 | .flutter-plugins-dependencies 32 | .packages 33 | .pub-cache/ 34 | .pub/ 35 | /build/ 36 | *.g.dart 37 | apollo.config.js 38 | schema.json 39 | /assets/*.md 40 | /assets/*.html 41 | 42 | # Android related 43 | **/android/**/gradle-wrapper.jar 44 | **/android/.gradle 45 | **/android/captures/ 46 | **/android/gradlew 47 | **/android/gradlew.bat 48 | **/android/local.properties 49 | **/android/**/GeneratedPluginRegistrant.java 50 | **/android/app/google-services.json 51 | **/android/app/debug.keystore 52 | **/android/key.properties 53 | 54 | # iOS/XCode related 55 | **/ios/**/*.mode1v3 56 | **/ios/**/*.mode2v3 57 | **/ios/**/*.moved-aside 58 | **/ios/**/*.pbxuser 59 | **/ios/**/*.perspectivev3 60 | **/ios/**/*sync/ 61 | **/ios/**/.sconsign.dblite 62 | **/ios/**/.tags* 63 | **/ios/**/.vagrant/ 64 | **/ios/**/DerivedData/ 65 | **/ios/**/Icon? 66 | **/ios/**/Pods/ 67 | **/ios/**/.symlinks/ 68 | **/ios/**/profile 69 | **/ios/**/xcuserdata 70 | **/ios/.generated/ 71 | **/ios/Flutter/App.framework 72 | **/ios/Flutter/Flutter.framework 73 | **/ios/Flutter/Generated.xcconfig 74 | **/ios/Flutter/app.flx 75 | **/ios/Flutter/app.zip 76 | **/ios/Flutter/flutter_assets/ 77 | **/ios/Flutter/flutter_export_environment.sh 78 | **/ios/ServiceDefinitions.json 79 | **/ios/Runner/GeneratedPluginRegistrant.* 80 | 81 | # Web related 82 | lib/generated_plugin_registrant.dart 83 | 84 | # Symbolication related 85 | app.*.symbols 86 | 87 | # Obfuscation related 88 | app.*.map.json 89 | # Exceptions to above rules. 90 | !**/ios/**/default.mode1v3 91 | !**/ios/**/default.mode2v3 92 | !**/ios/**/default.pbxuser 93 | !**/ios/**/default.perspectivev3 94 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 95 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: fba99f6cf9a14512e461e3122c8ddfaa25394e89 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Используйте IntelliSense, чтобы узнать о возможных атрибутах. 3 | // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. 4 | // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Integration Tests: Launch App", 9 | "request": "launch", 10 | "type": "dart", 11 | "program": "test_driver/app.dart", 12 | }, 13 | { 14 | "name": "Integration Tests: Launch Driver", 15 | "request": "launch", 16 | "type": "dart", 17 | "program": "test_driver/main_test.dart", 18 | }, 19 | { 20 | "name": "Dart: Run all Tests", 21 | "type": "dart", 22 | "request": "launch", 23 | "program": "./test/" 24 | }, 25 | { 26 | "name": "Flutter", 27 | "request": "launch", 28 | "type": "dart" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /COVER_LETTER_1.md: -------------------------------------------------------------------------------- 1 | Могу закрыть очень широкий круг вопросов. Я неформатный практик (IQ 137), нацеленный на самореализацию. Погружался в очередную технологию под призмой - как это пригодится мне для "свечного заводика"? 2 | 3 | - Столярка - 1989-1990 - некоторые разработчики приходят к ремеслу в качестве хобби, а у меня это первая профессия. И насмотрелся на ролевую модель: пролетарии (разработчики), бригадир (техлид), прораб (тимлид), начальник цеха (продукт). 4 | - Производство и продажа компьютерной техники - 1991-1999, "Искусство торговать" - это была моя настольная книга в системном интеграторе RAMEC; цифровизировал свою деятельность в Excel/Access и Delphi. 5 | - Delphi - 2000 - "выпускная работа" за 3 месяца после 5 лет учебной практики, веб-карта строящихся объектов - Ассоциация Домостроителей Петербурга, по заказу Сергея Полонского (тот самый, Mirax Group / Башня Федерация). 6 | - Winsyntax - 2001 - собственный редактор кода для PHP+JavaScript+HTML+CSS на Delphi. 7 | - PostgreSQL - 2001-2003 - проектирование баз данных для автоматизации предприятия (склад и оформление заказов). 8 | - UX дизайн - 2004 - полгода практики на интерфейсах интернет-магазина под руководством Платона Днепровского (преподаёт уже 13 лет курс по User Experience, Высшая Школа Экономики). 9 | - PHP - 2010 - "апогей карьеры" веб-разработчика в mamba.ru после 10 лет боевой практики, включая активности по эксплуатации продукта: кратная оптимизация нагрузки на железо, основал отдел тестирования, внедрил проактивный мониторинг лога ошибок, реализовал сбор информации по устройствам пользователей и функциональные доработки по гипотезам. 10 | - .NET & Java - 2005 - интеграция MS SharePoint & HP ServiceDesk в IT-отделе Петер-Сервис (биллинг для мобильных операторов). 11 | - ITIL - 2005 - прошёл несколько курсов для общего понимания. 12 | - Drupal - 2007-2015 - собственные UGC-проекты: конфигурирование и разработка плагинов, наполнение контентом, продвижение, монетизация, модерирование. 13 | - Linux-администрирование веб-сервера - с 2007 по мере необходимости для собственных проектов и понимания в постановке задач исполнителям. 14 | - Python - 2009 - прошёл курс веб-разработки на Django. 15 | - Android Java - 2010 - прототип мобильного приложения mamba.ru. 16 | - NodeJS - 2010-2018 - практика веб-разработки MeteorJS-PhaserJS-NextJS-GatsbyJS на уровне евангелиста - собственные курсы по ReactJS (решали вопрос найма бойцов). 17 | - Clojure - 2016 - короткая, но страстная любовь к Lisp (на базе JVM). 18 | - Elixir - 2018 - прошёл курс веб-разработки на Phoenix Framework. 19 | - GraphQL - 2018-2022 - практика на Hasura. 20 | - AWS - 2019 - прошёл вводный обзорный курс. 21 | - GoLang - 2021 - ботоводство в Телеграм: сбор 800 тыс. юзер-аккаунтов по заданной тематике, форвардер новостей с премодерацией, информеры по ценным бумагам, приём платежей и оформление подписки. 22 | - Hackintosh & Proxmox - 2022 - инсталляция MacOS на своём железе: 3 монитора 32" 4K и 24 GB RAM. 23 | - Flutter - 2019-2022 - live-code трансляции в Ютубе для регулярной практики разработчика и исследователя, осенью 2020-го провёл собственные курсы (учитель учится у своих учеников). 24 | - Kubernetes - 2022 - успел пощупать боевое применение лидом продукта "Цифровой Фермер" в РусАгро. 25 | 26 | Маркетинговые исследования - моя самая большая страсть, как говорил Олег Тиньков. Я могу часами-днями выбирать под разными углами очередную железку, или какую-то библиотеку. До волшебного озарения - момента выявления инсайтов. Цветные сны Банана - это мой наркотик. 27 | 28 | По техническому стеку я агностик. Но на сегодня предлагаю наиболее рациональные варианты, которые позволят "брать больше и кидать дальше" - это Flutter + GoLang. Развитие технологий идёт по спирали. Сложность рождает следующий уровень абстракции. Мы получаем потрясающе низкий порог входа и упрощения в решении технически сложных задач. 29 | 30 | Про выстраивание производственных процессов (как нанимать и применять разработчиков) - тоже съел собаку. Конечно, можно сделать по книжке, как у всех. Но это карго-культ - "эффективные менеджеры" осваивают бюджеты. Что хорошо для больших компаний, необязательно подходит к текущему этапу жизненного цикла продукта. Проповедую "Developer Centric Team", как конкурентное преимущество. Когда люди трудятся не за страх, а на совесть. Для этого я вижу в идеале практики: OKR vs KPI, эстимация задач по сложности vs по времени, коммуникация через BDD, fullstack-разработка, парное программирование, состояние потока, one2one-сессии и командная рефлексия. 31 | -------------------------------------------------------------------------------- /COVER_LETTER_2.md: -------------------------------------------------------------------------------- 1 | Здравствуйте! 2 | 3 | В настоящее время я прибываю в состоянии активного поиска применения себя на верхней части пирамиды Маслова. Пожалуйста, рассмотрите моё резюме. Что ещё могу добавить по вакансии? 4 | 5 | У меня разнообразный и длительный опыт активных путешествий. Объездил всю Европу на автомобиле. Брал на прокат автодом. Потом приобрёл прицеп-дачу доехал с ней до Испании. Затем выбрал лодку с мотором. Попал под смерч на Азовском море. Полгода стоял в лесу дикарём на берегу Финского залива. Увлёкся вело-электротранспортом, изучил все возможные варианты. Маркетинговые исследования - моя самая большая страсть. Я "проиндексировал" тонны информации по этой тематике. 6 | 7 | По части запуска и эксплуатации веб-проектов. У меня была своя сеть автоклубов. Запустил сайт billing.ru от формирования ТЗ до акта приёмки. Реализовал с нуля интернет-магазин для взрослых под большую нагрузку (на 2004 год). Эксплуатировал сервис знакомств под действительно большую нагрузку - 10 млн. активных пользователей. Есть опыт по всем ролям: маркетинг, бизнес анализ, UX/UI-дизайн, IT-администрирование, выбор стека и архитектурных решений, разработка и тестирование, различные формы продвижения и монетизации. 8 | 9 | Один в поле не воин. Коллектив - это рычаг, через который я могу брать больше и кидать дальше. Набивал шишки в этой сфере. Найм бойцов - холодный и через лабу ("кто хочет освоить технологию Х с трудоустройством"). Мероприятия на удержание: онбординг, регулярные one2one-сессии, покерное планирование, ретроспективы, повышение квалификации, аттестация. Всё это под флагом "developer-centic team". Ну и конечно сопутствующий негативный опыт подковёрных игр. Взаимодействие с людьми прокачивает эмоциональный интеллект. 10 | 11 | До перехода к веб-разработке в 2000 году, я был сравнительно самым успешным менеджером отдела оптовых и дилерских продаж в питерском системном интеграторе RAMEC. Нарабатывал навыки взаимодействия с клиентами и поставщиками. За 7 лет прошёл путь в коммерции от радио-рынка до пиджака в офисе: пиратка, собственное сборочное производство игровых компьютеров, "Основы маркетинга" Котлера и "Искусство торговать" Хопкинса. 12 | 13 | Нацелен на продолжительное и плодотворное сотрудничество. Выберите меня! 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 comerc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutter_idiomatic 2 | 3 | It is starter kit with idiomatic code structure :) Firebase Authentication & GraphQL CRUD via BLoC. With Unit tests, Widget tests and Integration tests as BDD. 4 | 5 | - [Заметка на Хабре](https://habr.com/ru/post/528106/) 6 | - https://youtu.be/rViOUxsGs2k 7 | - https://youtu.be/FndheiFSvPY 8 | - https://youtu.be/zIdsacU_y-k 9 | - [Альтернатива](https://habr.com/ru/company/atisu/blog/597709/) 10 | 11 | Inspired by [flutter_bloc example](https://github.com/felangel/bloc/tree/master/examples/flutter_firebase_login). 12 | 13 | ## How to Start 14 | 15 | ``` 16 | $ flutter packages pub run build_runner build --delete-conflicting-outputs 17 | ``` 18 | 19 | Add `lib/local.dart`: 20 | 21 | ```dart 22 | const kGitHubPersonalAccessToken = 'token'; 23 | // from https://github.com/settings/tokens 24 | 25 | const kDatabaseToken = 'token'; 26 | // from https://hasura.io/learn/graphql/graphiql?tutorial=react-native 27 | 28 | const kDatabaseUserId = 'your@email.com'; 29 | // from https://hasura.io/learn/graphql/graphiql?tutorial=react-native 30 | ``` 31 | 32 | for VSCode Apollo GraphQL 33 | 34 | ``` 35 | $ npm install -g apollo 36 | ``` 37 | 38 | create `./apollo.config.js` 39 | 40 | ```js 41 | module.exports = { 42 | client: { 43 | includes: ['./lib/**/*.dart'], 44 | service: { 45 | name: '', 46 | url: '', 47 | // optional headers 48 | headers: { 49 | 'x-hasura-admin-secret': '', 50 | 'x-hasura-role': 'user', 51 | }, 52 | // optional disable SSL validation check 53 | skipSSLValidation: true, 54 | // alternative way 55 | // localSchemaFile: './schema.json', 56 | }, 57 | }, 58 | } 59 | ``` 60 | 61 | how to download `schema.json` for `localSchemaFile` 62 | 63 | ``` 64 | $ apollo schema:download --endpoint --header 'X-Hasura-Admin-Secret: ' --header 'X-Hasura-Role: user' 65 | ``` 66 | 67 | ## Execution Test for flutter_test 68 | 69 | ``` 70 | # execute command line 71 | $ flutter test 72 | ``` 73 | 74 | ## Execution Test for flutter_driver 75 | 76 | ### Execute target to iOS / Android: 77 | 78 | - Use flutter devices to get target device id 79 | 80 | ``` 81 | $ flutter devices 82 | ``` 83 | 84 | - Config targetDeviceId in main_test.dart 85 | 86 | ``` 87 | Ex: (Android), default empty string 88 | ..targetDeviceId = "emulator-5554" 89 | ``` 90 | 91 | - Execute command line with target devices 92 | 93 | ``` 94 | $ flutter drive 95 | ``` 96 | 97 | ## Why BDD (Behavior Driven Development)? 98 | 99 | ![Swing Project](./assets/swing_project.png) 100 | 101 | > Flutter uses different types of tests [(unit, widget, integration)](https://flutter.dev/docs/testing). You should have all types of tests in your app, most of your tests should be unit tests, less widget and a few integration tests. The [test pyramid](https://martinfowler.com/bliki/TestPyramid.html) explains the principle well (using different words for the test-types). 102 | > 103 | > I want to help you to start with integration tests but go a step further than the description in the [flutter documentation](https://flutter.dev/docs/testing#integration-tests) and use the Gherkin language to describe the expected behavior. 104 | > The basic idea behind Gherkin/Cucumber is to have a semi-structured language to be able to define the expected behaviour and requirements in a way that all stakeholders of the project (customer, management, developer, QA, etc.) understand them. Using Gherkin helps to reduce misunderstandings, wasted resources and conflicts by improving the communication. Additionally, you get a documentation of your project and finally you can use the Gherkin files to run automated tests. 105 | > 106 | > If you write the Gherkin files, before you write the code, you have reached the final level, as this is called BDD (Behaviour Driven Development)! 107 | > 108 | > Here are some readings about BDD and Gherkin: 109 | > 110 | > - ["Introducing BDD", by Dan North (2006)](http://blog.dannorth.net/introducing-bdd) 111 | > - [Wikipedia](https://en.wikipedia.org/wiki/Behavior-driven_development) 112 | > - ["The beginner's guide to BDD (behaviour-driven development)", By Konstantin Kudryashov, Alistair Stead, Dan North](https://inviqa.com/blog/bdd-guide) 113 | > - [Behaviour-Driven Development](https://cucumber.io/docs/bdd/) 114 | > 115 | > ### The feature files 116 | > 117 | > The first line is just a title of the feature, the other three lines should answer the questions [Who, wants to achieve what and why with this particular feature](https://www.bibleserver.com/ESV/Luke15%3A4). If you cannot answer those questions for a particular feature of your app then you actually should not implement that feature, there is no use-case for it. 118 | 119 | ## Contacts 120 | 121 | - E-Mail: [andrew.kachanov@gmail.com](mailto:andrew.kachanov@gmail.com) 122 | - Telegram: [@AndrewKachanov](https://t.me/AndrewKachanov) 123 | 124 | ## Support Me 125 | 126 | - [Patreon](https://www.patreon.com/comerc) 127 | - [QIWI](https://donate.qiwi.com/payin/comerc) 128 | 129 | 😺 We love cats! 130 | -------------------------------------------------------------------------------- /RESUME.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/RESUME.pdf -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lint/analysis_options.yaml 2 | # include: package:pedantic/analysis_options.yaml 3 | 4 | linter: 5 | rules: 6 | unawaited_futures: true 7 | directives_ordering: false 8 | sort_pub_dependencies: false 9 | 10 | analyzer: 11 | strong-mode: 12 | # https://github.com/dart-lang/sdk/issues/25368 13 | implicit-casts: false 14 | # implicit-dynamic: false 15 | errors: 16 | # todo: true 17 | unnecessary_raw_strings: false 18 | prefer_const_literals_to_create_immutables: false 19 | prefer_const_constructors_in_immutables: false 20 | prefer_const_constructors: false 21 | prefer_const_declarations: false 22 | avoid_void_async: false 23 | no_runtimetype_tostring: false 24 | exclude: 25 | - .dart_tool/** 26 | - .editorconfig 27 | - .flutter-plugins 28 | - .flutter-plugins-dependencies 29 | - .gitignore 30 | - .idea/** 31 | - .pre-commit-config.yaml 32 | - .vscode/** 33 | - README.md 34 | - android 35 | - apps/** 36 | - assets/** 37 | - scripts/** 38 | - build.yaml 39 | - build/** 40 | - go/** 41 | - ios/** 42 | - lib/_**/** 43 | - lib/**.g.dart 44 | - lib/**.freezed.dart 45 | - application_bundle/** 46 | - linux/** 47 | - macos/** 48 | - plugins/** 49 | - pubspec.lock 50 | - pubspec.yaml 51 | - web/** 52 | - packages/** 53 | -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | -------------------------------------------------------------------------------- /android/app/build.gradle: -------------------------------------------------------------------------------- 1 | def localProperties = new Properties() 2 | def localPropertiesFile = rootProject.file('local.properties') 3 | if (localPropertiesFile.exists()) { 4 | localPropertiesFile.withReader('UTF-8') { reader -> 5 | localProperties.load(reader) 6 | } 7 | } 8 | 9 | def flutterRoot = localProperties.getProperty('flutter.sdk') 10 | if (flutterRoot == null) { 11 | throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") 12 | } 13 | 14 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 15 | if (flutterVersionCode == null) { 16 | flutterVersionCode = '1' 17 | } 18 | 19 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 20 | if (flutterVersionName == null) { 21 | flutterVersionName = '1.0' 22 | } 23 | 24 | apply plugin: 'com.android.application' 25 | apply plugin: 'com.google.gms.google-services' 26 | apply plugin: 'kotlin-android' 27 | apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" 28 | 29 | android { 30 | compileSdkVersion 28 31 | 32 | sourceSets { 33 | main.java.srcDirs += 'src/main/kotlin' 34 | } 35 | 36 | lintOptions { 37 | disable 'InvalidPackage' 38 | } 39 | 40 | defaultConfig { 41 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 42 | applicationId "com.arisesoft.flutter_idiomatic" 43 | minSdkVersion 16 44 | targetSdkVersion 28 45 | versionCode flutterVersionCode.toInteger() 46 | versionName flutterVersionName 47 | } 48 | 49 | buildTypes { 50 | release { 51 | // TODO: Add your own signing config for the release build. 52 | // Signing with the debug keys for now, so `flutter run --release` works. 53 | signingConfig signingConfigs.debug 54 | } 55 | } 56 | } 57 | 58 | flutter { 59 | source '../..' 60 | } 61 | 62 | dependencies { 63 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 64 | implementation platform('com.google.firebase:firebase-bom:25.12.0') 65 | } 66 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 8 | 12 | 19 | 23 | 27 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 43 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/flutter_firebase_login/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.arisesoft.flutter_idiomatic 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() { 6 | } 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.50' 3 | repositories { 4 | google() 5 | jcenter() 6 | } 7 | 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:3.5.0' 10 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 11 | classpath 'com.google.gms:google-services:4.3.4' 12 | } 13 | } 14 | 15 | allprojects { 16 | repositories { 17 | google() 18 | jcenter() 19 | } 20 | } 21 | 22 | rootProject.buildDir = '../build' 23 | subprojects { 24 | project.buildDir = "${rootProject.buildDir}/${project.name}" 25 | } 26 | subprojects { 27 | project.evaluationDependsOn(':app') 28 | } 29 | 30 | task clean(type: Delete) { 31 | delete rootProject.buildDir 32 | } 33 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.enableR8=true 3 | android.useAndroidX=true 4 | android.enableJetifier=true 5 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def localPropertiesFile = new File(rootProject.projectDir, "local.properties") 4 | def properties = new Properties() 5 | 6 | assert localPropertiesFile.exists() 7 | localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } 8 | 9 | def flutterSdkPath = properties.getProperty("flutter.sdk") 10 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 11 | apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" 12 | -------------------------------------------------------------------------------- /assets/bloc_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/assets/bloc_logo_small.png -------------------------------------------------------------------------------- /assets/swing_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/assets/swing_project.png -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json-schema.org/draft-07/schema# 2 | targets: 3 | $default: 4 | builders: 5 | json_serializable: 6 | generate_for: 7 | - lib/cubits/*.dart 8 | - lib/models/*.dart 9 | options: 10 | # Options configure how source code is generated for every 11 | # `@JsonSerializable`-annotated class in the package. 12 | # 13 | # The default value for each is listed. 14 | any_map: false 15 | checked: false 16 | constructor: "" 17 | create_factory: true 18 | create_to_json: true 19 | disallow_unrecognized_keys: false 20 | explicit_to_json: true # changed 21 | field_rename: snake # changed 22 | ignore_unannotated: false 23 | include_if_null: true 24 | -------------------------------------------------------------------------------- /ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Flutter Idiomatic 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | $(FLUTTER_BUILD_NUMBER) 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UISupportedInterfaceOrientations 30 | 31 | UIInterfaceOrientationPortrait 32 | UIInterfaceOrientationLandscapeLeft 33 | UIInterfaceOrientationLandscapeRight 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UIViewControllerBasedStatusBarAppearance 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /lib/common/cache_client.dart: -------------------------------------------------------------------------------- 1 | /// {@template cache_client} 2 | /// An in-memory cache client. 3 | /// {@endtemplate} 4 | class CacheClient { 5 | /// {@macro cache_client} 6 | CacheClient() : _cache = {}; 7 | 8 | final Map _cache; 9 | 10 | /// Writes the provide [key], [value] pair to the in-memory cache. 11 | void write({required String key, required T value}) { 12 | _cache[key] = value; 13 | } 14 | 15 | /// Looks up the value for the provided [key]. 16 | /// Defaults to `null` if no value exists for the provided key. 17 | T? read({required String key}) { 18 | final value = _cache[key]; 19 | if (value is T) return value; 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/common/const.dart: -------------------------------------------------------------------------------- 1 | const kGraphQLQueryTimeout = Duration(seconds: 15); 2 | const kGraphQLMutationTimeout = Duration(seconds: 15); 3 | -------------------------------------------------------------------------------- /lib/common/graphql_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:graphql/client.dart'; 2 | import 'package:gql/ast.dart'; 3 | 4 | class GraphQLService { 5 | GraphQLService({ 6 | required this.client, 7 | required this.queryTimeout, 8 | required this.mutationTimeout, 9 | this.fragments, 10 | }); 11 | 12 | final GraphQLClient client; 13 | final Duration queryTimeout; 14 | final Duration mutationTimeout; 15 | final DocumentNode? fragments; 16 | 17 | Future> query({ 18 | required DocumentNode document, 19 | required Map variables, 20 | String? root, 21 | dynamic Function(dynamic rawJson)? toRoot, 22 | required T Function(Map json) convert, 23 | }) async { 24 | final hasRoot = root != null && root.isNotEmpty; 25 | final hasToRoot = toRoot != null; 26 | assert(!(hasRoot && hasToRoot), 'Assign "root" or "toRoot" or nothing'); 27 | final options = QueryOptions( 28 | document: _addFragments(document), 29 | variables: variables, 30 | fetchPolicy: FetchPolicy.noCache, 31 | errorPolicy: ErrorPolicy.all, 32 | ); 33 | final queryResult = await client.query(options).timeout(queryTimeout); 34 | if (queryResult.hasException) { 35 | throw queryResult.exception!; 36 | } 37 | final rawJson = hasRoot 38 | ? queryResult.data![root] 39 | : hasToRoot 40 | ? toRoot!(queryResult.data) 41 | : queryResult.data; 42 | final jsons = (rawJson as List).cast>(); 43 | final result = []; 44 | for (final json in jsons) { 45 | result.add(convert(json)); 46 | } 47 | return result; 48 | } 49 | 50 | Future mutate({ 51 | required DocumentNode document, 52 | required Map variables, 53 | String? root, 54 | dynamic Function(dynamic rawJson)? toRoot, 55 | required T Function(Map json) convert, 56 | }) async { 57 | final hasRoot = root != null && root.isNotEmpty; 58 | final hasToRoot = toRoot != null; 59 | assert(!(hasRoot && hasToRoot), 'Assign "root" or "toRoot" or nothing'); 60 | final options = MutationOptions( 61 | document: _addFragments(document), 62 | variables: variables, 63 | fetchPolicy: FetchPolicy.noCache, 64 | errorPolicy: ErrorPolicy.all, 65 | ); 66 | final mutationResult = 67 | await client.mutate(options).timeout(mutationTimeout); 68 | if (mutationResult.hasException) { 69 | throw mutationResult.exception!; 70 | } 71 | final rawJson = hasRoot 72 | ? mutationResult.data![root] 73 | : hasToRoot 74 | ? toRoot!(mutationResult.data) 75 | : mutationResult.data; 76 | return (rawJson == null) ? null : convert(rawJson as Map); 77 | } 78 | 79 | Stream subscribe({ 80 | required DocumentNode document, 81 | required Map variables, 82 | String? root, 83 | dynamic Function(dynamic rawJson)? toRoot, 84 | required T Function(Map json) convert, 85 | }) { 86 | final hasRoot = root != null && root.isNotEmpty; 87 | final hasToRoot = toRoot != null; 88 | assert(!(hasRoot && hasToRoot), 'Assign "root" or "toRoot" or nothing'); 89 | final operation = SubscriptionOptions( 90 | document: _addFragments(document), 91 | variables: variables, 92 | fetchPolicy: FetchPolicy.noCache, 93 | errorPolicy: ErrorPolicy.all, 94 | ); 95 | return client.subscribe(operation).map((QueryResult queryResult) { 96 | if (queryResult.hasException) { 97 | throw queryResult.exception!; 98 | } 99 | final rawJson = hasRoot 100 | ? queryResult.data![root] 101 | : hasToRoot 102 | ? toRoot!(queryResult.data) 103 | : queryResult.data; 104 | return (rawJson == null) 105 | ? null 106 | : convert(rawJson as Map); 107 | }); 108 | } 109 | 110 | DocumentNode _addFragments(DocumentNode document) { 111 | return (fragments == null) 112 | ? document 113 | : DocumentNode( 114 | definitions: [...fragments!.definitions, ...document.definitions], 115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /lib/common/helpers.dart: -------------------------------------------------------------------------------- 1 | import 'package:bot_toast/bot_toast.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter_bloc/flutter_bloc.dart'; 5 | 6 | T getBloc>(BuildContext context) => 7 | BlocProvider.of(context); 8 | 9 | T getRepository(BuildContext context) => RepositoryProvider.of(context); 10 | 11 | void out(dynamic value) { 12 | if (kDebugMode) debugPrint('$value'); 13 | } 14 | 15 | class ValidationException implements Exception { 16 | ValidationException(this.message); 17 | 18 | final String message; 19 | 20 | @override 21 | String toString() { 22 | return message; 23 | } 24 | } 25 | 26 | Future load(Future Function() future) async { 27 | await Future.delayed(Duration.zero); // for render initial state 28 | try { 29 | await future(); 30 | } catch (error) { 31 | BotToast.showNotification( 32 | crossPage: false, 33 | title: (_) => Text('$error'), 34 | trailing: (Function close) => FlatButton( 35 | onLongPress: () {}, // чтобы сократить время для splashColor 36 | onPressed: () { 37 | close(); 38 | load(future); 39 | }, 40 | child: Text('Repeat'.toUpperCase()), 41 | ), 42 | ); 43 | return Future.error(error); 44 | } 45 | } 46 | 47 | Future save(Future Function() future) async { 48 | BotToast.showLoading(); 49 | try { 50 | await future(); 51 | } on ValidationException catch (error) { 52 | BotToast.showNotification( 53 | crossPage: false, 54 | title: (_) => Text('$error'), 55 | ); 56 | return Future.error(error); 57 | } catch (error) { 58 | BotToast.showNotification( 59 | // crossPage: true, // !!!! 60 | title: (_) => Text('$error'), 61 | trailing: (Function close) => FlatButton( 62 | onLongPress: () {}, // чтобы сократить время для splashColor 63 | onPressed: () { 64 | close(); 65 | save(future); 66 | }, 67 | child: Text('Repeat'.toUpperCase()), 68 | ), 69 | ); 70 | return Future.error(error); 71 | } finally { 72 | BotToast.closeAllLoading(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/common/route.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter/material.dart'; 4 | 5 | PageRoute buildRoute( 6 | String name, { 7 | required WidgetBuilder builder, 8 | bool fullscreenDialog = false, 9 | bool maintainState = true, 10 | bool isInitialRoute = false, 11 | }) { 12 | final settings = RouteSettings( 13 | name: name, 14 | // isInitialRoute: isInitialRoute, // deprecated 15 | ); 16 | if (isInitialRoute) { 17 | return Platform.isIOS 18 | ? NoAnimationCupertinoPageRoute( 19 | builder: builder, 20 | settings: settings, 21 | maintainState: maintainState, 22 | fullscreenDialog: fullscreenDialog, 23 | ) 24 | : NoAnimationMaterialPageRoute( 25 | builder: builder, 26 | settings: settings, 27 | maintainState: maintainState, 28 | fullscreenDialog: fullscreenDialog, 29 | ); 30 | } 31 | return Platform.isIOS 32 | ? CupertinoPageRoute( 33 | builder: builder, 34 | settings: settings, 35 | maintainState: maintainState, 36 | fullscreenDialog: fullscreenDialog, 37 | ) 38 | : MaterialPageRoute( 39 | builder: builder, 40 | settings: settings, 41 | maintainState: maintainState, 42 | fullscreenDialog: fullscreenDialog, 43 | ); 44 | } 45 | 46 | bool _isFirstTransitionDuration = false; 47 | 48 | class NoAnimationCupertinoPageRoute extends CupertinoPageRoute { 49 | NoAnimationCupertinoPageRoute({ 50 | required WidgetBuilder builder, 51 | RouteSettings? settings, 52 | bool maintainState = true, 53 | bool fullscreenDialog = false, 54 | }) : super( 55 | builder: builder, 56 | maintainState: maintainState, 57 | settings: settings, 58 | fullscreenDialog: fullscreenDialog, 59 | ); 60 | 61 | @override 62 | Widget buildTransitions( 63 | BuildContext context, 64 | Animation animation, 65 | Animation secondaryAnimation, 66 | Widget child, 67 | ) { 68 | return child; 69 | } 70 | 71 | @override 72 | Duration get transitionDuration { 73 | _isFirstTransitionDuration = !_isFirstTransitionDuration; 74 | // transitionDuration вызывается два раза на каждую анимацию; 75 | // для первого вызова обнуляю значение, для второго - возвращаю. 76 | return _isFirstTransitionDuration 77 | ? Duration.zero 78 | : Duration(milliseconds: 300); 79 | } 80 | } 81 | 82 | class NoAnimationMaterialPageRoute extends MaterialPageRoute { 83 | NoAnimationMaterialPageRoute({ 84 | required WidgetBuilder builder, 85 | RouteSettings? settings, 86 | bool maintainState = true, 87 | bool fullscreenDialog = false, 88 | }) : super( 89 | builder: builder, 90 | maintainState: maintainState, 91 | settings: settings, 92 | fullscreenDialog: fullscreenDialog, 93 | ); 94 | 95 | @override 96 | Widget buildTransitions( 97 | BuildContext context, 98 | Animation animation, 99 | Animation secondaryAnimation, 100 | Widget child, 101 | ) { 102 | return child; 103 | } 104 | 105 | @override 106 | Duration get transitionDuration { 107 | _isFirstTransitionDuration = !_isFirstTransitionDuration; 108 | // transitionDuration вызывается два раза на каждую анимацию; 109 | // для первого вызова обнуляю значение, для второго - возвращаю. 110 | return _isFirstTransitionDuration 111 | ? Duration.zero 112 | : Duration(milliseconds: 300); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/common/simple_bloc_observer.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc/bloc.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | class SimpleBlocObserver extends BlocObserver { 5 | @override 6 | void onError(BlocBase cubit, Object error, StackTrace stackTrace) { 7 | out(error); 8 | super.onError(cubit, error, stackTrace); 9 | } 10 | 11 | @override 12 | void onChange(BlocBase cubit, Change change) { 13 | out(change); 14 | super.onChange(cubit, change); 15 | } 16 | 17 | @override 18 | void onCreate(BlocBase cubit) { 19 | out('**** onCreate $cubit'); 20 | super.onCreate(cubit); 21 | } 22 | 23 | @override 24 | void onClose(BlocBase cubit) { 25 | out('**** onClose $cubit'); 26 | super.onClose(cubit); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/common/theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:google_fonts/google_fonts.dart'; 3 | 4 | final theme = ThemeData( 5 | textTheme: GoogleFonts.openSansTextTheme(), 6 | primaryColorDark: Color(0xFF0097A7), 7 | primaryColorLight: Color(0xFFB2EBF2), 8 | primaryColor: Color(0xFF00BCD4), 9 | colorScheme: ColorScheme.fromSwatch().copyWith(secondary: Color(0xFF009688)), 10 | scaffoldBackgroundColor: Color(0xFFE0F2F1), 11 | inputDecorationTheme: InputDecorationTheme( 12 | border: OutlineInputBorder( 13 | borderRadius: BorderRadius.circular(8), 14 | ), 15 | ), 16 | ); 17 | -------------------------------------------------------------------------------- /lib/cubits/authentication.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:bloc/bloc.dart'; 4 | import 'package:flutter_idiomatic/import.dart'; 5 | 6 | class AuthenticationCubit extends Cubit { 7 | AuthenticationCubit(AuthenticationRepository repository) 8 | : _repository = repository, 9 | super( 10 | repository.currentUser.isNotEmpty 11 | ? AuthenticationState.authenticated(repository.currentUser) 12 | : const AuthenticationState.unauthenticated(), 13 | ) { 14 | _userSubscription = repository.user.listen(changeUser); 15 | } 16 | 17 | final AuthenticationRepository _repository; 18 | late StreamSubscription _userSubscription; 19 | 20 | @override 21 | Future close() { 22 | _userSubscription.cancel(); 23 | return super.close(); 24 | } 25 | 26 | void changeUser(UserModel user) { 27 | final result = user == UserModel.empty 28 | ? AuthenticationState.unauthenticated() 29 | : AuthenticationState.authenticated(user); 30 | emit(result); 31 | } 32 | 33 | void requestLogout() { 34 | _repository.logOut(); 35 | } 36 | } 37 | 38 | // class AuthenticationBloc 39 | // extends Bloc { 40 | // AuthenticationBloc({ 41 | // @required AuthenticationRepository authenticationRepository, 42 | // }) : assert(authenticationRepository != null), 43 | // _authenticationRepository = authenticationRepository, 44 | // super(AuthenticationState.unknown()) { 45 | // _userSubscription = _authenticationRepository.user.listen( 46 | // (user) => add(AuthenticationUserChanged(user)), 47 | // ); 48 | // } 49 | 50 | // final AuthenticationRepository _authenticationRepository; 51 | // StreamSubscription _userSubscription; 52 | 53 | // @override 54 | // Stream mapEventToState( 55 | // AuthenticationEvent event, 56 | // ) async* { 57 | // if (event is AuthenticationUserChanged) { 58 | // yield _mapAuthenticationUserChangedToState(event); 59 | // } else if (event is AuthenticationLogoutRequested) { 60 | // unawaited(_authenticationRepository.logOut()); 61 | // } 62 | // } 63 | 64 | // @override 65 | // Future close() { 66 | // _userSubscription?.cancel(); 67 | // return super.close(); 68 | // } 69 | 70 | // AuthenticationState _mapAuthenticationUserChangedToState( 71 | // AuthenticationUserChanged event, 72 | // ) { 73 | // return event.user != UserModel.empty 74 | // ? AuthenticationState.authenticated(event.user) 75 | // : AuthenticationState.unauthenticated(); 76 | // } 77 | // } 78 | 79 | // abstract class AuthenticationEvent extends Equatable { 80 | // AuthenticationEvent(); 81 | 82 | // @override 83 | // List get props => []; 84 | // } 85 | 86 | // class AuthenticationUserChanged extends AuthenticationEvent { 87 | // AuthenticationUserChanged(this.user); 88 | 89 | // final UserModel user; 90 | 91 | // @override 92 | // List get props => [user]; 93 | // } 94 | 95 | // class AuthenticationLogoutRequested extends AuthenticationEvent {} 96 | 97 | enum AuthenticationStatus { 98 | authenticated, 99 | unauthenticated, 100 | // unknown, 101 | } 102 | 103 | class AuthenticationState extends Equatable { 104 | const AuthenticationState._({ 105 | required this.status, 106 | this.user = UserModel.empty, 107 | }); 108 | 109 | // const AuthenticationState.unknown() : this._(); 110 | 111 | const AuthenticationState.authenticated(UserModel user) 112 | : this._(status: AuthenticationStatus.authenticated, user: user); 113 | 114 | const AuthenticationState.unauthenticated() 115 | : this._(status: AuthenticationStatus.unauthenticated); 116 | 117 | final AuthenticationStatus status; 118 | final UserModel user; 119 | 120 | @override 121 | List get props => [status, user]; 122 | } 123 | -------------------------------------------------------------------------------- /lib/cubits/github_repositories.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 5 | import 'package:replay_bloc/replay_bloc.dart'; 6 | import 'package:flutter_idiomatic/import.dart'; 7 | 8 | part 'github_repositories.g.dart'; 9 | 10 | class GitHubRepositoriesCubit extends HydratedCubit 11 | with ReplayCubitMixin { 12 | GitHubRepositoriesCubit(GitHubRepository repository) 13 | : _repository = repository, 14 | super(GitHubRepositoriesState()); 15 | 16 | final GitHubRepository _repository; 17 | 18 | Future reset() async { 19 | emit(GitHubRepositoriesState()); 20 | } 21 | 22 | Future load() async { 23 | if (state.status == GitHubStatus.busy) return; 24 | emit(state.copyWith(status: GitHubStatus.busy)); 25 | try { 26 | final items = await _repository.readRepositories(); 27 | emit(state.copyWith( 28 | items: items, 29 | )); 30 | } catch (error) { 31 | return Future.error(error); 32 | } finally { 33 | emit(state.copyWith(status: GitHubStatus.ready)); 34 | } 35 | } 36 | 37 | List _updateStarLocally(String id, bool value) { 38 | final index = 39 | state.items.indexWhere((RepositoryModel item) => item.id == id); 40 | if (index == -1) { 41 | return state.items; 42 | } 43 | final items = [...state.items]; 44 | items[index] = items[index].copyWith(viewerHasStarred: value); 45 | return items; 46 | } 47 | 48 | Future toggleStar({required String id, required bool value}) async { 49 | emit(state.copyWith( 50 | items: _updateStarLocally(id, value), 51 | loadingItems: {...state.loadingItems}..add(id), 52 | )); 53 | try { 54 | await _repository.toggleStar(id: id, value: value); 55 | } catch (error) { 56 | emit(state.copyWith( 57 | items: _updateStarLocally(id, !value), 58 | )); 59 | return Future.error(error); 60 | } finally { 61 | emit(state.copyWith( 62 | loadingItems: {...state.loadingItems}..remove(id), 63 | )); 64 | } 65 | } 66 | 67 | @override 68 | GitHubRepositoriesState fromJson(Map json) => 69 | GitHubRepositoriesState.fromJson(json); 70 | 71 | @override 72 | Map toJson(GitHubRepositoriesState state) => state.toJson(); 73 | } 74 | 75 | enum GitHubStatus { initial, busy, ready } 76 | 77 | @CopyWith() 78 | @JsonSerializable() 79 | class GitHubRepositoriesState extends Equatable { 80 | GitHubRepositoriesState({ 81 | this.items = const [], 82 | this.status = GitHubStatus.initial, 83 | this.loadingItems = const {}, 84 | }); 85 | 86 | final List items; 87 | final GitHubStatus status; 88 | final Set loadingItems; 89 | 90 | @override 91 | List get props => [items, status, loadingItems]; 92 | 93 | factory GitHubRepositoriesState.fromJson(Map json) => 94 | _$GitHubRepositoriesStateFromJson(json); 95 | 96 | Map toJson() => _$GitHubRepositoriesStateToJson(this); 97 | } 98 | -------------------------------------------------------------------------------- /lib/cubits/login.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:formz/formz.dart'; 4 | import 'package:bloc/bloc.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | part 'login.g.dart'; 8 | 9 | class LoginCubit extends Cubit { 10 | LoginCubit(AuthenticationRepository repository) 11 | : assert(repository != null), 12 | _repository = repository, 13 | super(LoginState()); 14 | 15 | final AuthenticationRepository _repository; 16 | 17 | void doEmailChanged(String value) { 18 | final emailInput = EmailInputModel.dirty(value); 19 | emit(state.copyWith( 20 | emailInput: emailInput, 21 | status: Formz.validate([emailInput, state.passwordInput]), 22 | )); 23 | } 24 | 25 | void doPasswordChanged(String value) { 26 | final passwordInput = PasswordInputModel.dirty(value); 27 | emit(state.copyWith( 28 | passwordInput: passwordInput, 29 | status: Formz.validate([state.emailInput, passwordInput]), 30 | )); 31 | } 32 | 33 | Future logInWithCredentials() async { 34 | if (!state.status.isValidated) return; 35 | emit(state.copyWith(status: FormzStatus.submissionInProgress)); 36 | try { 37 | await _repository.logInWithEmailAndPassword( 38 | email: state.emailInput.value, 39 | password: state.passwordInput.value, 40 | ); 41 | emit(state.copyWith(status: FormzStatus.submissionSuccess)); 42 | } on LogInWithEmailAndPasswordFailure catch (e) { 43 | emit(state.copyWith( 44 | errorMessage: e.message, 45 | status: FormzStatus.submissionFailure, 46 | )); 47 | } catch (_) { 48 | emit(state.copyWith(status: FormzStatus.submissionFailure)); 49 | } 50 | } 51 | 52 | Future logInWithGoogle() async { 53 | emit(state.copyWith(status: FormzStatus.submissionInProgress)); 54 | try { 55 | await _repository.logInWithGoogle(); 56 | emit(state.copyWith(status: FormzStatus.submissionSuccess)); 57 | } on LogInWithGoogleFailure catch (e) { 58 | emit(state.copyWith( 59 | errorMessage: e.message, 60 | status: FormzStatus.submissionFailure, 61 | )); 62 | } catch (_) { 63 | emit(state.copyWith(status: FormzStatus.submissionFailure)); 64 | } 65 | } 66 | } 67 | 68 | @CopyWith() 69 | class LoginState extends Equatable { 70 | const LoginState({ 71 | this.emailInput = const EmailInputModel.pure(), 72 | this.passwordInput = const PasswordInputModel.pure(), 73 | this.status = FormzStatus.pure, 74 | this.errorMessage, 75 | }); 76 | 77 | final EmailInputModel emailInput; 78 | final PasswordInputModel passwordInput; 79 | // https://github.com/numen31337/copy_with_extension/pull/23 80 | // TODO: @CopyWithField(required: true) 81 | final FormzStatus status; 82 | final String? errorMessage; 83 | 84 | @override 85 | List get props { 86 | return [ 87 | emailInput, 88 | passwordInput, 89 | status, 90 | errorMessage, 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lib/cubits/sign_up.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:equatable/equatable.dart'; 3 | import 'package:formz/formz.dart'; 4 | import 'package:bloc/bloc.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | part 'sign_up.g.dart'; 8 | 9 | class SignUpCubit extends Cubit { 10 | SignUpCubit(AuthenticationRepository repository) 11 | : assert(repository != null), 12 | _repository = repository, 13 | super(SignUpState()); 14 | 15 | final AuthenticationRepository _repository; 16 | 17 | void doEmailChanged(String value) { 18 | final emailInput = EmailInputModel.dirty(value); 19 | emit(state.copyWith( 20 | emailInput: emailInput, 21 | status: Formz.validate([ 22 | emailInput, 23 | state.passwordInput, 24 | state.confirmedPasswordInput, 25 | ]), 26 | )); 27 | } 28 | 29 | void doPasswordChanged(String value) { 30 | final passwordInput = PasswordInputModel.dirty(value); 31 | final confirmedPasswordInput = ConfirmedPasswordInputModel.dirty( 32 | password: passwordInput.value, 33 | value: state.confirmedPasswordInput.value, 34 | ); 35 | emit(state.copyWith( 36 | passwordInput: passwordInput, 37 | confirmedPasswordInput: confirmedPasswordInput, 38 | status: Formz.validate([ 39 | state.emailInput, 40 | passwordInput, 41 | confirmedPasswordInput, 42 | ]), 43 | )); 44 | } 45 | 46 | void doConfirmedPasswordChanged(String value) { 47 | final confirmedPasswordInput = ConfirmedPasswordInputModel.dirty( 48 | password: state.passwordInput.value, 49 | value: value, 50 | ); 51 | emit(state.copyWith( 52 | confirmedPasswordInput: confirmedPasswordInput, 53 | status: Formz.validate([ 54 | state.emailInput, 55 | state.passwordInput, 56 | confirmedPasswordInput, 57 | ]), 58 | )); 59 | } 60 | 61 | Future signUpFormSubmitted() async { 62 | if (!state.status.isValidated) return; 63 | emit(state.copyWith(status: FormzStatus.submissionInProgress)); 64 | try { 65 | await _repository.signUp( 66 | email: state.emailInput.value, 67 | password: state.passwordInput.value, 68 | ); 69 | emit(state.copyWith(status: FormzStatus.submissionSuccess)); 70 | } on SignUpWithEmailAndPasswordFailure catch (e) { 71 | emit(state.copyWith( 72 | errorMessage: e.message, 73 | status: FormzStatus.submissionFailure, 74 | )); 75 | } catch (_) { 76 | emit(state.copyWith(status: FormzStatus.submissionFailure)); 77 | } 78 | } 79 | } 80 | 81 | @CopyWith() 82 | class SignUpState extends Equatable { 83 | const SignUpState({ 84 | this.emailInput = const EmailInputModel.pure(), 85 | this.passwordInput = const PasswordInputModel.pure(), 86 | this.confirmedPasswordInput = const ConfirmedPasswordInputModel.pure(), 87 | this.status = FormzStatus.pure, 88 | this.errorMessage, 89 | }); 90 | 91 | final EmailInputModel emailInput; 92 | final PasswordInputModel passwordInput; 93 | final ConfirmedPasswordInputModel confirmedPasswordInput; 94 | // https://github.com/numen31337/copy_with_extension/pull/23 95 | // TODO: @CopyWithField(required: true) 96 | final FormzStatus status; 97 | final String? errorMessage; 98 | 99 | @override 100 | List get props { 101 | return [ 102 | emailInput, 103 | passwordInput, 104 | confirmedPasswordInput, 105 | status, 106 | errorMessage, 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/cubits/todos.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:copy_with_extension/copy_with_extension.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | import 'package:flutter/foundation.dart'; 5 | import 'package:json_annotation/json_annotation.dart'; 6 | import 'package:characters/characters.dart'; 7 | import 'package:bloc/bloc.dart'; 8 | import 'package:flutter_idiomatic/import.dart'; 9 | 10 | part 'todos.g.dart'; 11 | 12 | class TodosCubit extends Cubit { 13 | TodosCubit(DatabaseRepository repository) 14 | : _repository = repository, 15 | super(TodosState()) { 16 | _fetchNewNotificationSubscription = 17 | repository.fetchNewTodoNotification.listen(fetchNewNotification); 18 | } 19 | 20 | final DatabaseRepository _repository; 21 | late StreamSubscription _fetchNewNotificationSubscription; 22 | bool _isStartedSubscription = false; 23 | 24 | @override 25 | Future close() { 26 | _fetchNewNotificationSubscription.cancel(); 27 | return super.close(); 28 | } 29 | 30 | void fetchNewNotification(int? id) { 31 | if (!_isStartedSubscription) { 32 | _isStartedSubscription = true; 33 | return; 34 | } 35 | emit(state.copyWith(newId: id)); 36 | } 37 | 38 | Future load({required TodosOrigin origin}) async { 39 | const kLimit = 10; 40 | if (state.status == TodosStatus.loading) return; 41 | emit(state.copyWith( 42 | status: TodosStatus.loading, 43 | origin: origin, 44 | // errorMessage: '', 45 | )); 46 | try { 47 | final items = await _repository.readTodos( 48 | createdAt: origin == TodosOrigin.loadMore ? state.nextDateTime : null, 49 | limit: kLimit + 1, 50 | ); 51 | var hasMore = false; 52 | DateTime? nextDateTime; 53 | if (items.length == kLimit + 1) { 54 | hasMore = true; 55 | final lastItem = items.removeLast(); 56 | nextDateTime = lastItem.createdAt; 57 | } 58 | if (origin != TodosOrigin.loadMore) { 59 | emit(TodosState()); 60 | await Future.delayed(Duration(milliseconds: 300)); 61 | } 62 | emit(state.copyWith( 63 | items: [...state.items, ...items], 64 | hasMore: hasMore, 65 | nextDateTime: nextDateTime, 66 | )); 67 | // } catch (error) { 68 | // emit(state.copyWith(errorMessage: '$error')); 69 | // return Future.error(error); 70 | } finally { 71 | emit(state.copyWith( 72 | status: TodosStatus.ready, 73 | origin: TodosOrigin.initial, 74 | )); 75 | } 76 | } 77 | 78 | Future remove(int id) async { 79 | emit(state.copyWith( 80 | items: [...state.items]..removeWhere((TodoModel item) => item.id == id), 81 | )); 82 | try { 83 | final deletedId = await _repository.deleteTodo(id); 84 | if (deletedId != id) { 85 | throw Exception('Can not remove todo $id'); 86 | } 87 | } catch (error) { 88 | return Future.error(error); 89 | } 90 | } 91 | 92 | Future add(TodosData data) async { 93 | // final titleInput = TitleInputModel.dirty(title); 94 | // final status = Formz.validate([titleInput]); 95 | // if (status.isInvalid) { 96 | // return Future.error(ValidationException(titleInput.error)); 97 | // } 98 | // emit(state.copyWith(isSubmitMode: true)); 99 | // try { 100 | if (data.title.characters.length < 4) { 101 | throw ValidationException('Invalid input < 4 characters'); 102 | } 103 | final item = await _repository.createTodo(data); 104 | if (item == null) return; 105 | emit(state.copyWith( 106 | items: [item, ...state.items], 107 | )); 108 | // } catch (error) { 109 | // return Future.error(error); 110 | // } finally { 111 | // emit(state.copyWith(isSubmitMode: false)); 112 | // } 113 | } 114 | } 115 | 116 | enum TodosStatus { initial, loading, ready } 117 | enum TodosOrigin { initial, start, refreshIndicator, loadNew, loadMore } 118 | 119 | @CopyWith() 120 | class TodosState extends Equatable { 121 | TodosState({ 122 | this.items = const [], 123 | this.status = TodosStatus.initial, 124 | this.origin = TodosOrigin.initial, 125 | this.hasMore = false, 126 | this.nextDateTime, 127 | this.newId, 128 | // this.isSubmitMode = false, 129 | // this.errorMessage = '', 130 | }); 131 | 132 | final List items; 133 | final TodosStatus status; 134 | final TodosOrigin origin; 135 | final DateTime? nextDateTime; 136 | final bool hasMore; 137 | final int? newId; 138 | // final bool isSubmitMode; 139 | // final String errorMessage; 140 | 141 | bool get hasReallyNewId => 142 | newId != null && 143 | items.indexWhere((TodoModel item) => item.id == newId) == -1; 144 | 145 | @override 146 | List get props => [ 147 | items, 148 | status, 149 | origin, 150 | hasMore, 151 | nextDateTime, 152 | newId, 153 | // isSubmitMode, 154 | // errorMessage, 155 | ]; 156 | } 157 | 158 | @JsonSerializable(createFactory: false) 159 | class TodosData { 160 | TodosData({required this.title}); 161 | 162 | final String title; 163 | 164 | Map toJson() => _$TodosDataToJson(this); 165 | } 166 | -------------------------------------------------------------------------------- /lib/import.dart: -------------------------------------------------------------------------------- 1 | // общий import исключает дублирование на глобальные имена внутри проекта 2 | export 'common/cache_client.dart'; 3 | export 'common/const.dart'; 4 | export 'common/graphql_service.dart'; 5 | export 'common/helpers.dart'; 6 | export 'common/route.dart'; 7 | export 'common/simple_bloc_observer.dart'; 8 | export 'common/theme.dart'; 9 | export 'cubits/authentication.dart'; 10 | export 'cubits/github_repositories.dart'; 11 | export 'cubits/login.dart'; 12 | export 'cubits/sign_up.dart'; 13 | export 'cubits/todos.dart'; 14 | export 'local.dart'; 15 | export 'main.dart'; 16 | export 'models/confirmed_password_input.dart'; 17 | export 'models/email_input.dart'; 18 | export 'models/password_input.dart'; 19 | export 'models/repository.dart'; 20 | export 'models/todo.dart'; 21 | export 'models/user.dart'; 22 | export 'repositories/authentication.dart'; 23 | export 'repositories/database_api.dart'; 24 | export 'repositories/database.dart'; 25 | export 'repositories/github_api.dart'; 26 | export 'repositories/github.dart'; 27 | export 'screens/github_repositories.dart'; 28 | export 'screens/home.dart'; 29 | export 'screens/login.dart'; 30 | export 'screens/sign_up.dart'; 31 | export 'screens/splash.dart'; 32 | export 'screens/todos.dart'; 33 | export 'widgets/avatar.dart'; 34 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:equatable/equatable.dart'; 5 | import 'package:firebase_core/firebase_core.dart'; 6 | import 'package:flutter_bloc/flutter_bloc.dart'; 7 | import 'package:hydrated_bloc/hydrated_bloc.dart'; 8 | import 'package:bot_toast/bot_toast.dart'; 9 | import 'package:path_provider/path_provider.dart'; 10 | // import 'package:flutter/scheduler.dart' show timeDilation; 11 | import 'package:flutter_idiomatic/import.dart'; 12 | 13 | // TODO: прикрутить json_serializable_immutable_collections & built_collection (смотри minsk8) 14 | 15 | void main() { 16 | // timeDilation = 10.0; // Will slow down animations by a factor of two 17 | // debugPaintSizeEnabled = true; 18 | // FlutterError.onError = (FlutterErrorDetails details) { 19 | // if (kDebugMode) { 20 | // // In development mode, simply print to console. 21 | // FlutterError.dumpErrorToConsole(details); 22 | // } else { 23 | // // In production mode, report to the application zone to report to 24 | // // Sentry. 25 | // Zone.current.handleUncaughtError(details.exception, details.stack); 26 | // } 27 | // }; 28 | runZonedGuarded>(() async { 29 | WidgetsFlutterBinding.ensureInitialized(); 30 | await Firebase.initializeApp(); 31 | EquatableConfig.stringify = kDebugMode; 32 | // Bloc.observer = SimpleBlocObserver(); 33 | // TODO: delete follow code after migrate 34 | // HydratedBloc.storage = await HydratedStorage.build(); 35 | // runApp( 36 | // App( 37 | // authenticationRepository: AuthenticationRepository(), 38 | // gitHubRepository: GitHubRepository(), 39 | // databaseRepository: DatabaseRepository(), 40 | // ), 41 | // ); 42 | final storage = await HydratedStorage.build( 43 | storageDirectory: kIsWeb 44 | ? HydratedStorage.webStorageDirectory 45 | : await getTemporaryDirectory(), 46 | ); 47 | HydratedBlocOverrides.runZoned( 48 | () => runApp( 49 | App( 50 | authenticationRepository: AuthenticationRepository(), 51 | gitHubRepository: GitHubRepository(), 52 | databaseRepository: DatabaseRepository(), 53 | ), 54 | ), 55 | storage: storage, 56 | ); 57 | }, (error, stackTrace) { 58 | out('**** runZonedGuarded ****'); 59 | out('$error'); 60 | out('$stackTrace'); 61 | // Whenever an error occurs, call the `_reportError` function. This sends 62 | // Dart errors to the dev console or Sentry depending on the environment. 63 | // _reportError(error, stackTrace); 64 | }); 65 | } 66 | 67 | class App extends StatelessWidget { 68 | App({ 69 | Key? key, 70 | required this.authenticationRepository, 71 | required this.gitHubRepository, 72 | required this.databaseRepository, 73 | }) : assert(authenticationRepository != null), 74 | assert(gitHubRepository != null), 75 | assert(databaseRepository != null), 76 | super(key: key); 77 | 78 | final AuthenticationRepository authenticationRepository; 79 | final GitHubRepository gitHubRepository; 80 | final DatabaseRepository databaseRepository; 81 | 82 | @override 83 | Widget build(BuildContext context) { 84 | return MultiRepositoryProvider( 85 | providers: [ 86 | RepositoryProvider.value( 87 | value: authenticationRepository, 88 | ), 89 | RepositoryProvider.value( 90 | value: gitHubRepository, 91 | ), 92 | RepositoryProvider.value( 93 | value: databaseRepository, 94 | ), 95 | ], 96 | child: BlocProvider( 97 | create: (BuildContext context) => 98 | AuthenticationCubit(authenticationRepository), 99 | child: AppView(), 100 | ), 101 | ); 102 | } 103 | } 104 | 105 | final navigatorKey = GlobalKey(); 106 | 107 | NavigatorState get navigator => navigatorKey.currentState!; 108 | 109 | class AppView extends StatelessWidget { 110 | @override 111 | Widget build(BuildContext context) { 112 | return MaterialApp( 113 | theme: theme, 114 | navigatorKey: navigatorKey, 115 | navigatorObservers: [ 116 | BotToastNavigatorObserver(), 117 | ], 118 | // home: TodosScreen(), 119 | builder: (BuildContext context, Widget? child) { 120 | var result = child; 121 | result = BlocListener( 122 | listener: (BuildContext context, AuthenticationState state) { 123 | final cases = { 124 | AuthenticationStatus.authenticated: () { 125 | navigator.pushAndRemoveUntil( 126 | HomeScreen().getRoute(), 127 | (Route route) => false, 128 | ); 129 | }, 130 | AuthenticationStatus.unauthenticated: () { 131 | navigator.pushAndRemoveUntil( 132 | LoginScreen().getRoute(), 133 | (Route route) => false, 134 | ); 135 | }, 136 | }; 137 | assert(cases.length == AuthenticationStatus.values.length); 138 | cases[state.status]!(); 139 | }, 140 | child: result, 141 | ); 142 | result = BotToastInit()(context, result); 143 | return result; 144 | }, 145 | onGenerateRoute: (_) => SplashScreen().getRoute(), 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /lib/models/confirmed_password_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:formz/formz.dart'; 3 | 4 | enum ConfirmedPasswordInputValidationError { invalid } 5 | 6 | class ConfirmedPasswordInputModel 7 | extends FormzInput { 8 | const ConfirmedPasswordInputModel.pure({this.password = ''}) : super.pure(''); 9 | const ConfirmedPasswordInputModel.dirty({ 10 | required this.password, 11 | String value = '', 12 | }) : super.dirty(value); 13 | 14 | final String password; 15 | 16 | @override 17 | ConfirmedPasswordInputValidationError? validator(String value) { 18 | return password == value 19 | ? null 20 | : ConfirmedPasswordInputValidationError.invalid; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/models/email_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:formz/formz.dart'; 2 | 3 | enum EmailInputValidationError { invalid } 4 | 5 | class EmailInputModel extends FormzInput { 6 | const EmailInputModel.pure() : super.pure(''); 7 | const EmailInputModel.dirty([String value = '']) : super.dirty(value); 8 | 9 | static final RegExp _emailRegExp = RegExp( 10 | r'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$', 11 | ); 12 | 13 | @override 14 | EmailInputValidationError? validator(String value) { 15 | return _emailRegExp.hasMatch(value) 16 | ? null 17 | : EmailInputValidationError.invalid; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/models/password_input.dart: -------------------------------------------------------------------------------- 1 | import 'package:formz/formz.dart'; 2 | 3 | enum PasswordInputValidationError { invalid } 4 | 5 | class PasswordInputModel 6 | extends FormzInput { 7 | const PasswordInputModel.pure() : super.pure(''); 8 | const PasswordInputModel.dirty([String value = '']) : super.dirty(value); 9 | 10 | static final _passwordRegExp = 11 | RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'); 12 | 13 | @override 14 | PasswordInputValidationError? validator(String value) { 15 | return _passwordRegExp.hasMatch(value) 16 | ? null 17 | : PasswordInputValidationError.invalid; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/models/repository.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | 5 | part 'repository.g.dart'; 6 | 7 | @CopyWith() 8 | @JsonSerializable(fieldRename: FieldRename.none) 9 | class RepositoryModel extends Equatable { 10 | RepositoryModel({ 11 | required this.id, 12 | required this.name, 13 | required this.viewerHasStarred, 14 | }); 15 | 16 | final String id; 17 | final String name; 18 | final bool viewerHasStarred; 19 | 20 | @override 21 | List get props => [id, name, viewerHasStarred]; 22 | 23 | static RepositoryModel fromJson(Map json) => 24 | _$RepositoryModelFromJson(json); 25 | 26 | Map toJson() => _$RepositoryModelToJson(this); 27 | } 28 | -------------------------------------------------------------------------------- /lib/models/todo.dart: -------------------------------------------------------------------------------- 1 | import 'package:copy_with_extension/copy_with_extension.dart'; 2 | import 'package:json_annotation/json_annotation.dart'; 3 | import 'package:equatable/equatable.dart'; 4 | 5 | part 'todo.g.dart'; 6 | 7 | @CopyWith() 8 | @JsonSerializable() 9 | class TodoModel extends Equatable { 10 | TodoModel({ 11 | required this.id, 12 | required this.title, 13 | required this.createdAt, 14 | }); 15 | 16 | final int id; 17 | final String title; 18 | final DateTime createdAt; 19 | 20 | @override 21 | List get props => [id, title, createdAt]; 22 | 23 | static TodoModel fromJson(Map json) => 24 | _$TodoModelFromJson(json); 25 | 26 | Map toJson() => _$TodoModelToJson(this); 27 | } 28 | -------------------------------------------------------------------------------- /lib/models/user.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | /// {@template user} 4 | /// User model 5 | /// 6 | /// [UserModel.empty] represents an unauthenticated user. 7 | /// {@endtemplate} 8 | class UserModel extends Equatable { 9 | /// {@macro user} 10 | const UserModel({ 11 | required this.id, 12 | this.email, 13 | this.name, 14 | this.photo, 15 | }); 16 | 17 | /// The current user's id. 18 | final String id; 19 | 20 | /// The current user's email address. 21 | final String? email; 22 | 23 | /// The current user's name (display name). 24 | final String? name; 25 | 26 | /// Url for the current user's photo. 27 | final String? photo; 28 | 29 | /// Empty user which represents an unauthenticated user. 30 | static const empty = UserModel(id: ''); 31 | 32 | /// Convenience getter to determine whether the current user is empty. 33 | bool get isEmpty => this == UserModel.empty; 34 | 35 | /// Convenience getter to determine whether the current user is not empty. 36 | bool get isNotEmpty => this != UserModel.empty; 37 | 38 | @override 39 | List get props => [ 40 | id, 41 | email, 42 | name, 43 | photo, 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /lib/repositories/authentication.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:firebase_auth/firebase_auth.dart'; 4 | import 'package:google_sign_in/google_sign_in.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | /// {@template sign_up_with_email_and_password_failure} 8 | /// Thrown if during the sign up process if a failure occurs. 9 | /// {@endtemplate} 10 | class SignUpWithEmailAndPasswordFailure implements Exception { 11 | /// {@macro sign_up_with_email_and_password_failure} 12 | const SignUpWithEmailAndPasswordFailure([ 13 | this.message = 'An unknown exception occurred.', 14 | ]); 15 | 16 | /// Create an authentication message 17 | /// from a firebase authentication exception code. 18 | /// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/createUserWithEmailAndPassword.html 19 | factory SignUpWithEmailAndPasswordFailure.fromCode(String code) { 20 | switch (code) { 21 | case 'invalid-email': 22 | return const SignUpWithEmailAndPasswordFailure( 23 | 'Email is not valid or badly formatted.', 24 | ); 25 | case 'user-disabled': 26 | return const SignUpWithEmailAndPasswordFailure( 27 | 'This user has been disabled. Please contact support for help.', 28 | ); 29 | case 'email-already-in-use': 30 | return const SignUpWithEmailAndPasswordFailure( 31 | 'An account already exists for that email.', 32 | ); 33 | case 'operation-not-allowed': 34 | return const SignUpWithEmailAndPasswordFailure( 35 | 'Operation is not allowed. Please contact support.', 36 | ); 37 | case 'weak-password': 38 | return const SignUpWithEmailAndPasswordFailure( 39 | 'Please enter a stronger password.', 40 | ); 41 | default: 42 | return const SignUpWithEmailAndPasswordFailure(); 43 | } 44 | } 45 | 46 | /// The associated error message. 47 | final String message; 48 | } 49 | 50 | /// {@template log_in_with_email_and_password_failure} 51 | /// Thrown during the login process if a failure occurs. 52 | /// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/signInWithEmailAndPassword.html 53 | /// {@endtemplate} 54 | class LogInWithEmailAndPasswordFailure implements Exception { 55 | /// {@macro log_in_with_email_and_password_failure} 56 | const LogInWithEmailAndPasswordFailure([ 57 | this.message = 'An unknown exception occurred.', 58 | ]); 59 | 60 | /// Create an authentication message 61 | /// from a firebase authentication exception code. 62 | factory LogInWithEmailAndPasswordFailure.fromCode(String code) { 63 | switch (code) { 64 | case 'invalid-email': 65 | return const LogInWithEmailAndPasswordFailure( 66 | 'Email is not valid or badly formatted.', 67 | ); 68 | case 'user-disabled': 69 | return const LogInWithEmailAndPasswordFailure( 70 | 'This user has been disabled. Please contact support for help.', 71 | ); 72 | case 'user-not-found': 73 | return const LogInWithEmailAndPasswordFailure( 74 | 'Email is not found, please create an account.', 75 | ); 76 | case 'wrong-password': 77 | return const LogInWithEmailAndPasswordFailure( 78 | 'Incorrect password, please try again.', 79 | ); 80 | default: 81 | return const LogInWithEmailAndPasswordFailure(); 82 | } 83 | } 84 | 85 | /// The associated error message. 86 | final String message; 87 | } 88 | 89 | /// {@template log_in_with_google_failure} 90 | /// Thrown during the sign in with google process if a failure occurs. 91 | /// https://pub.dev/documentation/firebase_auth/latest/firebase_auth/FirebaseAuth/signInWithCredential.html 92 | /// {@endtemplate} 93 | class LogInWithGoogleFailure implements Exception { 94 | /// {@macro log_in_with_google_failure} 95 | const LogInWithGoogleFailure([ 96 | this.message = 'An unknown exception occurred.', 97 | ]); 98 | 99 | /// Create an authentication message 100 | /// from a firebase authentication exception code. 101 | factory LogInWithGoogleFailure.fromCode(String code) { 102 | switch (code) { 103 | case 'account-exists-with-different-credential': 104 | return const LogInWithGoogleFailure( 105 | 'Account exists with different credentials.', 106 | ); 107 | case 'invalid-credential': 108 | return const LogInWithGoogleFailure( 109 | 'The credential received is malformed or has expired.', 110 | ); 111 | case 'operation-not-allowed': 112 | return const LogInWithGoogleFailure( 113 | 'Operation is not allowed. Please contact support.', 114 | ); 115 | case 'user-disabled': 116 | return const LogInWithGoogleFailure( 117 | 'This user has been disabled. Please contact support for help.', 118 | ); 119 | case 'user-not-found': 120 | return const LogInWithGoogleFailure( 121 | 'Email is not found, please create an account.', 122 | ); 123 | case 'wrong-password': 124 | return const LogInWithGoogleFailure( 125 | 'Incorrect password, please try again.', 126 | ); 127 | case 'invalid-verification-code': 128 | return const LogInWithGoogleFailure( 129 | 'The credential verification code received is invalid.', 130 | ); 131 | case 'invalid-verification-id': 132 | return const LogInWithGoogleFailure( 133 | 'The credential verification ID received is invalid.', 134 | ); 135 | default: 136 | return const LogInWithGoogleFailure(); 137 | } 138 | } 139 | 140 | /// The associated error message. 141 | final String message; 142 | } 143 | 144 | /// Thrown during the logout process if a failure occurs. 145 | class LogOutFailure implements Exception {} 146 | 147 | /// {@template authentication_repository} 148 | /// Repository which manages user authentication. 149 | /// {@endtemplate} 150 | class AuthenticationRepository { 151 | /// {@macro authentication_repository} 152 | AuthenticationRepository({ 153 | CacheClient? cacheClient, 154 | FirebaseAuth? firebaseAuth, 155 | GoogleSignIn? googleSignIn, 156 | }) : _cacheClient = cacheClient ?? CacheClient(), 157 | _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance, 158 | _googleSignIn = googleSignIn ?? GoogleSignIn.standard(); 159 | 160 | final CacheClient _cacheClient; 161 | final FirebaseAuth _firebaseAuth; 162 | final GoogleSignIn _googleSignIn; 163 | 164 | /// Whether or not the current environment is web 165 | /// Should only be overriden for testing purposes. Otherwise, 166 | /// defaults to [kIsWeb] 167 | @visibleForTesting 168 | bool isWeb = kIsWeb; 169 | 170 | /// User cache key. 171 | /// Should only be used for testing purposes. 172 | @visibleForTesting 173 | static const userCacheKey = '__user_cache_key__'; 174 | 175 | /// Stream of [User] which will emit the current user when 176 | /// the authentication state changes. 177 | /// 178 | /// Emits [User.empty] if the user is not authenticated. 179 | Stream get user { 180 | return _firebaseAuth.authStateChanges().map((User? firebaseUser) { 181 | final user = 182 | firebaseUser == null ? UserModel.empty : firebaseUser.toUserModel; 183 | _cacheClient.write(key: userCacheKey, value: user); 184 | return user; 185 | }); 186 | } 187 | 188 | /// Returns the current cached user. 189 | /// Defaults to [UserModel.empty] if there is no cached user. 190 | UserModel get currentUser { 191 | return _cacheClient.read(key: userCacheKey) ?? UserModel.empty; 192 | } 193 | 194 | /// Creates a new user with the provided [email] and [password]. 195 | /// 196 | /// Throws a [SignUpFailure] if an exception occurs. 197 | Future signUp({ 198 | required String email, 199 | required String password, 200 | }) async { 201 | try { 202 | await _firebaseAuth.createUserWithEmailAndPassword( 203 | email: email, 204 | password: password, 205 | ); 206 | } on FirebaseAuthException catch (e) { 207 | throw SignUpWithEmailAndPasswordFailure.fromCode(e.code); 208 | } catch (_) { 209 | throw const SignUpWithEmailAndPasswordFailure(); 210 | } 211 | } 212 | 213 | /// Starts the Sign In with Google Flow. 214 | /// 215 | /// Throws a [LogInWithEmailAndPasswordFailure] if an exception occurs. 216 | Future logInWithGoogle() async { 217 | try { 218 | final googleUser = await _googleSignIn.signIn(); 219 | final googleAuth = await googleUser!.authentication; 220 | final credential = GoogleAuthProvider.credential( 221 | accessToken: googleAuth.accessToken, 222 | idToken: googleAuth.idToken, 223 | ); 224 | await _firebaseAuth.signInWithCredential(credential); 225 | } on Exception { 226 | throw LogInWithGoogleFailure(); 227 | } 228 | } 229 | 230 | /// Signs in with the provided [email] and [password]. 231 | /// 232 | /// Throws a [LogInWithEmailAndPasswordFailure] if an exception occurs. 233 | Future logInWithEmailAndPassword({ 234 | required String email, 235 | required String password, 236 | }) async { 237 | try { 238 | await _firebaseAuth.signInWithEmailAndPassword( 239 | email: email, 240 | password: password, 241 | ); 242 | } on Exception { 243 | throw LogInWithEmailAndPasswordFailure(); 244 | } 245 | } 246 | 247 | /// Signs out the current user which will emit 248 | /// [User.empty] from the [user] Stream. 249 | /// 250 | /// Throws a [LogOutFailure] if an exception occurs. 251 | Future logOut() async { 252 | try { 253 | await Future.wait([ 254 | _firebaseAuth.signOut(), 255 | _googleSignIn.signOut(), 256 | ]); 257 | } on Exception { 258 | throw LogOutFailure(); 259 | } 260 | } 261 | } 262 | 263 | extension on User { 264 | UserModel get toUserModel { 265 | return UserModel( 266 | id: uid, 267 | email: email, 268 | name: displayName, 269 | photo: photoURL, 270 | ); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /lib/repositories/database.dart: -------------------------------------------------------------------------------- 1 | import 'package:graphql/client.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | const _kEnableWebsockets = true; 5 | 6 | class DatabaseRepository { 7 | DatabaseRepository({ 8 | GraphQLService? service, 9 | }) : _service = service ?? 10 | GraphQLService( 11 | client: _createClient(), 12 | queryTimeout: kGraphQLQueryTimeout, 13 | mutationTimeout: kGraphQLMutationTimeout, 14 | ); 15 | 16 | final GraphQLService _service; 17 | 18 | Future> readTodos( 19 | {DateTime? createdAt, required int limit}) async { 20 | return _service.query( 21 | document: DatabaseAPI.readTodos, 22 | variables: { 23 | 'user_id': kDatabaseUserId, 24 | 'created_at': (createdAt ?? DateTime.now().toUtc()).toIso8601String(), 25 | 'limit': limit, 26 | }, 27 | root: 'todos', 28 | convert: TodoModel.fromJson, 29 | ); 30 | } 31 | 32 | Stream get fetchNewTodoNotification { 33 | return _service.subscribe( 34 | document: DatabaseAPI.fetchNewTodoNotification, 35 | variables: {'user_id': kDatabaseUserId}, 36 | // ignore: avoid_dynamic_calls 37 | toRoot: (dynamic rawJson) => rawJson['todos'][0], 38 | convert: (Map json) => json['id'] as int, 39 | ); 40 | } 41 | 42 | Future deleteTodo(int id) async { 43 | return _service.mutate( 44 | document: DatabaseAPI.deleteTodo, 45 | variables: {'id': id}, 46 | root: 'delete_todos_by_pk', 47 | convert: (Map json) => json['id'] as int, 48 | ); 49 | } 50 | 51 | Future createTodo(TodosData data) async { 52 | return _service.mutate( 53 | document: DatabaseAPI.createTodo, 54 | variables: data.toJson(), 55 | root: 'insert_todos_one', 56 | convert: TodoModel.fromJson, 57 | ); 58 | } 59 | } 60 | 61 | GraphQLClient _createClient() { 62 | final httpLink = HttpLink( 63 | 'https://hasura.io/learn/graphql', 64 | ); 65 | final authLink = AuthLink( 66 | getToken: () async => 'Bearer $kDatabaseToken', 67 | ); 68 | var link = authLink.concat(httpLink); 69 | if (_kEnableWebsockets) { 70 | final websocketLink = WebSocketLink( 71 | 'wss://hasura.io/learn/graphql', 72 | config: SocketClientConfig( 73 | inactivityTimeout: const Duration(seconds: 15), 74 | initialPayload: () async { 75 | out('**** initPayload'); 76 | return { 77 | 'headers': {'Authorization': 'Bearer $kDatabaseToken'}, 78 | }; 79 | }, 80 | ), 81 | ); 82 | link = link.concat(websocketLink); 83 | } 84 | return GraphQLClient( 85 | cache: GraphQLCache(), 86 | // cache: NormalizedInMemoryCache( 87 | // dataIdFromObject: typenameDataIdFromObject, 88 | // ), 89 | // cache: OptimisticCache( 90 | // dataIdFromObject: typenameDataIdFromObject, 91 | // ), 92 | link: link, 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /lib/repositories/database_api.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: require_trailing_commas 2 | import 'package:graphql/client.dart'; 3 | 4 | mixin DatabaseAPI { 5 | static final createTodo = gql(r''' 6 | mutation CreateTodo($title: String) { 7 | insert_todos_one(object: {title: $title}) { 8 | ...TodosFields 9 | } 10 | } 11 | ''')..definitions.addAll(fragments.definitions); 12 | 13 | static final deleteTodo = gql(r''' 14 | mutation DeleteTodo($id: Int!) { 15 | delete_todos_by_pk(id: $id) { 16 | id 17 | } 18 | } 19 | '''); 20 | 21 | static final fetchNewTodoNotification = gql(r''' 22 | subscription FetchNewTodoNotification($user_id: String!) { 23 | todos( 24 | where: { 25 | user_id: {_eq: $user_id}, 26 | # is_public: {_eq: true}, 27 | }, 28 | order_by: {created_at: desc}, 29 | limit: 1, 30 | ) { 31 | id 32 | } 33 | } 34 | '''); 35 | 36 | static final readTodos = gql(r''' 37 | query ReadTodos($user_id: String!, $created_at: timestamptz!, $limit: Int!) { 38 | todos( 39 | where: { 40 | user_id: {_eq: $user_id}, 41 | created_at: {_lte: $created_at}, 42 | }, 43 | order_by: { created_at: desc }, 44 | limit: $limit, 45 | ) { 46 | ...TodosFields 47 | } 48 | } 49 | ''')..definitions.addAll(fragments.definitions); 50 | 51 | static final fragments = gql(r''' 52 | fragment TodosFields on todos { 53 | # __typename 54 | id 55 | title 56 | created_at 57 | } 58 | '''); 59 | } 60 | -------------------------------------------------------------------------------- /lib/repositories/github.dart: -------------------------------------------------------------------------------- 1 | import 'package:graphql/client.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | const _kEnableWebsockets = false; 5 | const _kRepositoriesLimit = 8; 6 | 7 | class GitHubRepository { 8 | GitHubRepository({ 9 | GraphQLService? service, 10 | }) : _service = service ?? 11 | GraphQLService( 12 | client: _createClient(), 13 | queryTimeout: kGraphQLQueryTimeout, 14 | mutationTimeout: kGraphQLMutationTimeout, 15 | ); 16 | 17 | final GraphQLService _service; 18 | 19 | Future> readRepositories() async { 20 | return _service.query( 21 | document: GitHubAPI.readRepositories, 22 | variables: {'nRepositories': _kRepositoriesLimit}, 23 | // ignore: avoid_dynamic_calls 24 | toRoot: (dynamic rawJson) => rawJson['viewer']['repositories']['nodes'], 25 | convert: RepositoryModel.fromJson, 26 | ); 27 | } 28 | 29 | Future toggleStar({required String id, required bool value}) async { 30 | return _service.mutate( 31 | document: value ? GitHubAPI.addStar : GitHubAPI.removeStar, 32 | variables: {'starrableId': id}, 33 | // ignore: avoid_dynamic_calls 34 | toRoot: (dynamic rawJson) => rawJson['action']['starrable'], 35 | convert: (Map json) => json['viewerHasStarred'] as bool, 36 | ); 37 | } 38 | } 39 | 40 | GraphQLClient _createClient() { 41 | final httpLink = HttpLink( 42 | 'https://api.github.com/graphql', 43 | ); 44 | final authLink = AuthLink( 45 | getToken: () async => 'Bearer $kGitHubPersonalAccessToken', 46 | ); 47 | var link = authLink.concat(httpLink); 48 | if (_kEnableWebsockets) { 49 | final websocketLink = WebSocketLink( 50 | 'ws://localhost:8080/ws/graphql', 51 | config: SocketClientConfig( 52 | inactivityTimeout: Duration(seconds: 15), 53 | // initPayload: () async => { 54 | // 'headers': {'Authorization': 'Bearer ' + token} 55 | // }, 56 | ), 57 | ); 58 | link = link.concat(websocketLink); 59 | } 60 | return GraphQLClient( 61 | cache: GraphQLCache(), 62 | // cache: NormalizedInMemoryCache( 63 | // dataIdFromObject: typenameDataIdFromObject, 64 | // ), 65 | // cache: OptimisticCache( 66 | // dataIdFromObject: typenameDataIdFromObject, 67 | // ), 68 | link: link, 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /lib/repositories/github_api.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: require_trailing_commas 2 | import 'package:graphql/client.dart'; 3 | 4 | mixin GitHubAPI { 5 | static final fragments = gql(r''' 6 | fragment RepositoryFields on Repository { 7 | # __typename 8 | id 9 | name 10 | viewerHasStarred 11 | } 12 | '''); 13 | 14 | static final readRepositories = gql(r''' 15 | query ReadRepositories($nRepositories: Int!) { 16 | viewer { 17 | repositories(last: $nRepositories) { 18 | nodes { 19 | ...RepositoryFields 20 | } 21 | } 22 | } 23 | } 24 | ''')..definitions.addAll(fragments.definitions); 25 | 26 | static final addStar = gql(r''' 27 | mutation AddStar($starrableId: ID!) { 28 | action: addStar(input: {starrableId: $starrableId}) { 29 | starrable { 30 | viewerHasStarred 31 | } 32 | } 33 | } 34 | '''); 35 | 36 | static final removeStar = gql(r''' 37 | mutation RemoveStar($starrableId: ID!) { 38 | action: removeStar(input: {starrableId: $starrableId}) { 39 | starrable { 40 | viewerHasStarred 41 | } 42 | } 43 | } 44 | '''); 45 | 46 | // static final searchRepositories = gql(r''' 47 | // query SearchRepositories($nRepositories: Int!, $query: String!, $cursor: String) { 48 | // search(last: $nRepositories, query: $query, type: REPOSITORY, after: $cursor) { 49 | // nodes { 50 | // # __typename 51 | // ... on Repository { 52 | // name 53 | // shortDescriptionHTML 54 | // viewerHasStarred 55 | // stargazers { 56 | // totalCount 57 | // } 58 | // forks { 59 | // totalCount 60 | // } 61 | // updatedAt 62 | // } 63 | // } 64 | // pageInfo { 65 | // endCursor 66 | // hasNextPage 67 | // } 68 | // } 69 | // } 70 | // '''); // ..definitions.addAll(fragments.definitions); 71 | } 72 | -------------------------------------------------------------------------------- /lib/repositories/storage.dart: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/comerc/flutter_idiomatic/a0e985cf4f5df19988b21c72c97126abe3b59508/lib/repositories/storage.dart -------------------------------------------------------------------------------- /lib/screens/github_repositories.dart: -------------------------------------------------------------------------------- 1 | import 'package:bot_toast/bot_toast.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_idiomatic/import.dart'; 5 | 6 | class GitHubRepositoriesScreen extends StatelessWidget { 7 | Route getRoute() { 8 | return buildRoute( 9 | '/github', 10 | builder: (_) => this, 11 | fullscreenDialog: true, 12 | ); 13 | } 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return BlocProvider( 18 | create: (BuildContext context) => 19 | GitHubRepositoriesCubit(getRepository(context)), 20 | child: Scaffold( 21 | appBar: AppBar( 22 | title: Text('GitHub Repositories'), 23 | actions: [ 24 | _ActionButton( 25 | title: 'Undo'.toUpperCase(), 26 | buildOnPressed: (GitHubRepositoriesCubit cubit) => 27 | cubit.canUndo ? cubit.undo : null, 28 | ), 29 | _ActionButton( 30 | title: 'Reset'.toUpperCase(), 31 | buildOnPressed: (GitHubRepositoriesCubit cubit) => cubit.reset, 32 | ), 33 | _ActionButton( 34 | title: 'Redo'.toUpperCase(), 35 | buildOnPressed: (GitHubRepositoriesCubit cubit) => 36 | cubit.canRedo ? cubit.redo : null, 37 | ), 38 | ], 39 | ), 40 | body: GitHubRepositoriesBody(), 41 | ), 42 | ); 43 | } 44 | } 45 | 46 | class GitHubRepositoriesBody extends StatelessWidget { 47 | @override 48 | Widget build(BuildContext context) { 49 | return BlocBuilder( 50 | builder: (BuildContext context, GitHubRepositoriesState state) { 51 | if (state.status == GitHubStatus.initial && state.items.isEmpty) { 52 | return Center( 53 | child: FloatingActionButton( 54 | onPressed: () { 55 | load(() => getBloc(context).load()); 56 | }, 57 | child: Icon(Icons.replay), 58 | )); 59 | } 60 | if (state.status == GitHubStatus.busy && state.items.isEmpty) { 61 | return Center(child: CircularProgressIndicator()); 62 | } 63 | return Column( 64 | children: [ 65 | Expanded( 66 | child: ListView.builder( 67 | itemCount: state.items.length + 1, 68 | itemBuilder: (BuildContext context, int index) { 69 | if (index == state.items.length) { 70 | if (state.status == GitHubStatus.busy) { 71 | return Center(child: CircularProgressIndicator()); 72 | } 73 | if (state.status == GitHubStatus.ready) { 74 | return Center( 75 | child: FlatButton( 76 | shape: StadiumBorder(), 77 | onPressed: () { 78 | load(() => getBloc(context) 79 | .load()); 80 | }, 81 | child: Text( 82 | 'Refresh'.toUpperCase(), 83 | style: TextStyle(color: theme.primaryColor), 84 | ), 85 | ), 86 | ); 87 | } 88 | return Container(); 89 | } 90 | final item = state.items[index]; 91 | return _Item( 92 | key: Key(item.id), 93 | item: item, 94 | isLoading: state.loadingItems.contains(item.id), 95 | ); 96 | }, 97 | ), 98 | ), 99 | ], 100 | ); 101 | }, 102 | ); 103 | } 104 | } 105 | 106 | class _Item extends StatelessWidget { 107 | _Item({ 108 | Key? key, 109 | required this.item, 110 | required this.isLoading, 111 | }) : super(key: key); 112 | 113 | final RepositoryModel item; 114 | final bool isLoading; 115 | 116 | @override 117 | Widget build(BuildContext context) { 118 | return Tooltip( 119 | preferBelow: false, 120 | message: 'Toggle Star', 121 | child: ListTile( 122 | leading: item.viewerHasStarred 123 | ? Icon( 124 | Icons.star, 125 | color: Colors.amber, 126 | ) 127 | : Icon(Icons.star_border), 128 | trailing: isLoading ? CircularProgressIndicator() : null, 129 | title: Text(item.name), 130 | onTap: () { 131 | _toggleStar(getBloc(context)); 132 | }, 133 | ), 134 | ); 135 | } 136 | 137 | Future _toggleStar(GitHubRepositoriesCubit cubit) async { 138 | final value = !item.viewerHasStarred; 139 | try { 140 | await cubit.toggleStar( 141 | id: item.id, 142 | value: value, 143 | ); 144 | } on Exception { 145 | BotToast.showNotification( 146 | title: (_) => Text( 147 | value 148 | ? 'Can not starred "${item.name}"' 149 | : 'Can not unstarred "${item.name}"', 150 | overflow: TextOverflow.fade, 151 | softWrap: false, 152 | ), 153 | trailing: (Function close) => FlatButton( 154 | onLongPress: () {}, // чтобы сократить время для splashColor 155 | onPressed: () { 156 | close(); 157 | _toggleStar(cubit); 158 | }, 159 | child: Text('Repeat'.toUpperCase()), 160 | ), 161 | ); 162 | } 163 | } 164 | } 165 | 166 | class _ActionButton extends StatelessWidget { 167 | const _ActionButton({ 168 | Key? key, 169 | required this.title, 170 | required this.buildOnPressed, 171 | }) : super(key: key); 172 | 173 | final String title; 174 | final VoidCallback? Function(GitHubRepositoriesCubit cubit) buildOnPressed; 175 | 176 | @override 177 | Widget build(BuildContext context) { 178 | return BlocBuilder( 179 | builder: (BuildContext context, GitHubRepositoriesState state) { 180 | final cubit = getBloc(context); 181 | return Padding( 182 | padding: const EdgeInsets.all(8.0), 183 | child: RaisedButton( 184 | elevation: 0, 185 | onPressed: buildOnPressed(cubit), 186 | child: Text( 187 | title, 188 | style: TextStyle(color: theme.primaryColor), 189 | ), 190 | ), 191 | ); 192 | }, 193 | ); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /lib/screens/home.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | class HomeScreen extends StatelessWidget { 5 | Route getRoute() { 6 | return buildRoute( 7 | '/home', 8 | builder: (_) => this, 9 | ); 10 | } 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final textTheme = Theme.of(context).textTheme; 15 | final user = getBloc(context).state.user; 16 | return Scaffold( 17 | appBar: AppBar( 18 | title: Text('Home'), 19 | actions: [_LogoutButton()], 20 | ), 21 | body: Align( 22 | alignment: Alignment(0, -1 / 3), 23 | child: Column( 24 | mainAxisSize: MainAxisSize.min, 25 | children: [ 26 | Avatar(photo: user.photo), 27 | SizedBox(height: 4), 28 | Text(user.email ?? '', style: textTheme.headline6), 29 | SizedBox(height: 4), 30 | Text(user.name ?? '', style: textTheme.headline5), 31 | SizedBox(height: 4), 32 | _GitHubRepositoriesButton(), 33 | SizedBox(height: 4), 34 | _TodosButton(), 35 | ], 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | 42 | class _LogoutButton extends StatelessWidget { 43 | @override 44 | Widget build(BuildContext context) { 45 | return IconButton( 46 | key: Key('$runtimeType'), 47 | icon: Icon(Icons.exit_to_app), 48 | onPressed: () => getBloc(context).requestLogout(), 49 | ); 50 | } 51 | } 52 | 53 | class _GitHubRepositoriesButton extends StatelessWidget { 54 | @override 55 | Widget build(BuildContext context) { 56 | final theme = Theme.of(context); 57 | return RaisedButton( 58 | key: Key('$runtimeType'), 59 | shape: StadiumBorder(), 60 | color: theme.accentColor, 61 | onPressed: () => 62 | navigator.push(GitHubRepositoriesScreen().getRoute()), 63 | child: Text( 64 | 'GitHub Repositories', 65 | style: TextStyle(color: Colors.white), 66 | ), 67 | ); 68 | } 69 | } 70 | 71 | class _TodosButton extends StatelessWidget { 72 | @override 73 | Widget build(BuildContext context) { 74 | final theme = Theme.of(context); 75 | return RaisedButton( 76 | key: Key('$runtimeType'), 77 | shape: StadiumBorder(), 78 | color: theme.accentColor, 79 | onPressed: () => navigator.push(TodosScreen().getRoute()), 80 | child: Text( 81 | 'Todos', 82 | style: TextStyle(color: Colors.white), 83 | ), 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /lib/screens/login.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:font_awesome_flutter/font_awesome_flutter.dart'; 4 | import 'package:formz/formz.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | class LoginScreen extends StatelessWidget { 8 | Route getRoute() { 9 | return buildRoute( 10 | '/login', 11 | builder: (_) => this, 12 | ); 13 | } 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Scaffold( 18 | appBar: AppBar(title: Text('Login')), 19 | body: BlocProvider( 20 | create: (BuildContext context) => 21 | LoginCubit(getRepository(context)), 22 | child: LoginForm(), 23 | ), 24 | ); 25 | } 26 | } 27 | 28 | class LoginForm extends StatelessWidget { 29 | @override 30 | Widget build(BuildContext context) { 31 | return BlocListener( 32 | listener: (BuildContext context, LoginState state) { 33 | if (state.status.isSubmissionFailure) { 34 | Scaffold.of(context) 35 | ..hideCurrentSnackBar() 36 | ..showSnackBar( 37 | SnackBar(content: Text('Authentication Failure')), 38 | ); 39 | } 40 | }, 41 | child: Padding( 42 | padding: EdgeInsets.all(8), 43 | child: Align( 44 | alignment: Alignment(0, -1 / 3), 45 | child: Column( 46 | mainAxisSize: MainAxisSize.min, 47 | children: [ 48 | Image.asset( 49 | 'assets/bloc_logo_small.png', 50 | height: 120, 51 | ), 52 | SizedBox(height: 16), 53 | _EmailInput(), 54 | SizedBox(height: 8), 55 | _PasswordInput(), 56 | SizedBox(height: 8), 57 | _LoginButton(), 58 | SizedBox(height: 8), 59 | _GoogleLoginButton(), 60 | SizedBox(height: 4), 61 | _SignUpButton(), 62 | ], 63 | ), 64 | ), 65 | ), 66 | ); 67 | } 68 | } 69 | 70 | class _EmailInput extends StatelessWidget { 71 | @override 72 | Widget build(BuildContext context) { 73 | return BlocBuilder( 74 | buildWhen: (LoginState previous, LoginState current) => 75 | previous.emailInput != current.emailInput, 76 | builder: (BuildContext context, LoginState state) { 77 | return TextField( 78 | key: Key('$runtimeType'), 79 | onChanged: (String value) => 80 | getBloc(context).doEmailChanged(value), 81 | keyboardType: TextInputType.emailAddress, 82 | decoration: InputDecoration( 83 | labelText: 'email', 84 | helperText: '', 85 | errorText: state.emailInput.invalid ? 'invalid email' : null, 86 | ), 87 | ); 88 | }, 89 | ); 90 | } 91 | } 92 | 93 | class _PasswordInput extends StatelessWidget { 94 | @override 95 | Widget build(BuildContext context) { 96 | return BlocBuilder( 97 | buildWhen: (LoginState previous, LoginState current) => 98 | previous.passwordInput != current.passwordInput, 99 | builder: (BuildContext context, LoginState state) { 100 | return TextField( 101 | key: Key('$runtimeType'), 102 | onChanged: (String value) => 103 | getBloc(context).doPasswordChanged(value), 104 | obscureText: true, 105 | decoration: InputDecoration( 106 | labelText: 'password', 107 | helperText: '', 108 | errorText: state.passwordInput.invalid ? 'invalid password' : null, 109 | ), 110 | ); 111 | }, 112 | ); 113 | } 114 | } 115 | 116 | class _LoginButton extends StatelessWidget { 117 | @override 118 | Widget build(BuildContext context) { 119 | return BlocBuilder( 120 | buildWhen: (LoginState previous, LoginState current) => 121 | previous.status != current.status, 122 | builder: (BuildContext context, LoginState state) { 123 | return state.status.isSubmissionInProgress 124 | ? CircularProgressIndicator() 125 | : RaisedButton( 126 | key: Key('$runtimeType'), 127 | shape: StadiumBorder(), 128 | color: Color(0xFFFFD600), 129 | onPressed: state.status.isValidated 130 | ? () => getBloc(context).logInWithCredentials() 131 | : null, 132 | child: Text('Login'.toUpperCase()), 133 | ); 134 | }, 135 | ); 136 | } 137 | } 138 | 139 | class _GoogleLoginButton extends StatelessWidget { 140 | @override 141 | Widget build(BuildContext context) { 142 | final theme = Theme.of(context); 143 | return RaisedButton.icon( 144 | key: Key('$runtimeType'), 145 | label: Text( 146 | 'Sign In with Google'.toUpperCase(), 147 | style: TextStyle(color: Colors.white), 148 | ), 149 | shape: StadiumBorder(), 150 | icon: Icon(FontAwesomeIcons.google, color: Colors.white), 151 | color: theme.accentColor, 152 | onPressed: () => getBloc(context).logInWithGoogle(), 153 | ); 154 | } 155 | } 156 | 157 | class _SignUpButton extends StatelessWidget { 158 | @override 159 | Widget build(BuildContext context) { 160 | final theme = Theme.of(context); 161 | return FlatButton( 162 | key: Key('$runtimeType'), 163 | shape: StadiumBorder(), 164 | onPressed: () => navigator.push(SignUpScreen().getRoute()), 165 | child: Text( 166 | 'Create Account'.toUpperCase(), 167 | style: TextStyle(color: theme.primaryColor), 168 | ), 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/screens/sign_up.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:formz/formz.dart'; 4 | import 'package:flutter_idiomatic/import.dart'; 5 | 6 | class SignUpScreen extends StatelessWidget { 7 | Route getRoute() { 8 | return buildRoute( 9 | '/sign_up', 10 | builder: (_) => this, 11 | ); 12 | } 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return Scaffold( 17 | appBar: AppBar(title: Text('Sign Up')), 18 | body: BlocProvider( 19 | create: (BuildContext context) => 20 | SignUpCubit(getRepository(context)), 21 | child: SignUpForm(), 22 | ), 23 | ); 24 | } 25 | } 26 | 27 | class SignUpForm extends StatelessWidget { 28 | @override 29 | Widget build(BuildContext context) { 30 | return BlocListener( 31 | listener: (BuildContext context, SignUpState state) { 32 | if (state.status.isSubmissionFailure) { 33 | Scaffold.of(context) 34 | ..hideCurrentSnackBar() 35 | ..showSnackBar( 36 | SnackBar(content: Text('Sign Up Failure')), 37 | ); 38 | } 39 | }, 40 | child: Padding( 41 | padding: EdgeInsets.all(8), 42 | child: Align( 43 | alignment: Alignment(0, -1 / 3), 44 | child: Column( 45 | mainAxisSize: MainAxisSize.min, 46 | children: [ 47 | _EmailInput(), 48 | SizedBox(height: 8), 49 | _PasswordInput(), 50 | SizedBox(height: 8), 51 | _ConfirmPasswordInput(), 52 | SizedBox(height: 8), 53 | _SignUpButton(), 54 | ], 55 | ), 56 | ), 57 | ), 58 | ); 59 | } 60 | } 61 | 62 | class _EmailInput extends StatelessWidget { 63 | @override 64 | Widget build(BuildContext context) { 65 | return BlocBuilder( 66 | buildWhen: (SignUpState previous, SignUpState current) => 67 | previous.emailInput != current.emailInput, 68 | builder: (BuildContext context, SignUpState state) { 69 | return TextField( 70 | key: Key('$runtimeType'), 71 | onChanged: (String value) => 72 | getBloc(context).doEmailChanged(value), 73 | keyboardType: TextInputType.emailAddress, 74 | decoration: InputDecoration( 75 | labelText: 'email', 76 | helperText: '', 77 | errorText: state.emailInput.invalid ? 'invalid email' : null, 78 | ), 79 | ); 80 | }, 81 | ); 82 | } 83 | } 84 | 85 | class _PasswordInput extends StatelessWidget { 86 | @override 87 | Widget build(BuildContext context) { 88 | return BlocBuilder( 89 | buildWhen: (SignUpState previous, SignUpState current) => 90 | previous.passwordInput != current.passwordInput, 91 | builder: (BuildContext context, SignUpState state) { 92 | return TextField( 93 | key: Key('$runtimeType'), 94 | onChanged: (String value) => 95 | getBloc(context).doPasswordChanged(value), 96 | obscureText: true, 97 | decoration: InputDecoration( 98 | labelText: 'password', 99 | helperText: '', 100 | errorText: state.passwordInput.invalid ? 'invalid password' : null, 101 | ), 102 | ); 103 | }, 104 | ); 105 | } 106 | } 107 | 108 | class _ConfirmPasswordInput extends StatelessWidget { 109 | @override 110 | Widget build(BuildContext context) { 111 | return BlocBuilder( 112 | buildWhen: (SignUpState previous, SignUpState current) => 113 | previous.passwordInput != current.passwordInput || 114 | previous.confirmedPasswordInput != current.confirmedPasswordInput, 115 | builder: (context, state) { 116 | return TextField( 117 | key: Key('$runtimeType'), 118 | onChanged: (String value) => 119 | getBloc(context).doConfirmedPasswordChanged(value), 120 | obscureText: true, 121 | decoration: InputDecoration( 122 | labelText: 'confirm password', 123 | helperText: '', 124 | errorText: state.confirmedPasswordInput.invalid 125 | ? 'passwords do not match' 126 | : null, 127 | ), 128 | ); 129 | }, 130 | ); 131 | } 132 | } 133 | 134 | class _SignUpButton extends StatelessWidget { 135 | @override 136 | Widget build(BuildContext context) { 137 | return BlocBuilder( 138 | buildWhen: (SignUpState previous, SignUpState current) => 139 | previous.status != current.status, 140 | builder: (BuildContext context, SignUpState state) { 141 | return state.status.isSubmissionInProgress 142 | ? CircularProgressIndicator() 143 | : RaisedButton( 144 | key: Key('$runtimeType'), 145 | shape: StadiumBorder(), 146 | color: Colors.orangeAccent, 147 | onPressed: state.status.isValidated 148 | ? () => getBloc(context).signUpFormSubmitted() 149 | : null, 150 | child: Text('Sign Up'.toUpperCase()), 151 | ); 152 | }, 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /lib/screens/splash.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | class SplashScreen extends StatelessWidget { 5 | Route getRoute() { 6 | return buildRoute( 7 | '/splash', 8 | builder: (_) => this, 9 | fullscreenDialog: true, 10 | ); 11 | } 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Scaffold( 16 | body: Center( 17 | child: _Logo(), 18 | ), 19 | ); 20 | } 21 | } 22 | 23 | class _Logo extends StatelessWidget { 24 | @override 25 | Widget build(BuildContext context) { 26 | return Image.asset( 27 | 'assets/bloc_logo_small.png', 28 | key: Key('$runtimeType'), 29 | width: 150, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/widgets/avatar.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | double _kAvatarSize = 48; 4 | 5 | class Avatar extends StatelessWidget { 6 | Avatar({Key? key, this.photo}) : super(key: key); 7 | 8 | final String? photo; 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return CircleAvatar( 13 | radius: _kAvatarSize, 14 | backgroundImage: photo != null ? NetworkImage(photo!) : null, 15 | child: 16 | photo == null ? Icon(Icons.person_outline, size: _kAvatarSize) : null, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_idiomatic 2 | description: It is starter kit with idiomatic code structure :) 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `pub publish`. This is preferred for private packages. 6 | publish_to: "none" # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.12.0 <3.0.0" 22 | 23 | dependencies: 24 | flutter: 25 | sdk: flutter 26 | 27 | # The following adds the Cupertino Icons font to your application. 28 | # Use with the CupertinoIcons class for iOS style icons. 29 | cupertino_icons: ^1.0.4 30 | 31 | font_awesome_flutter: ^9.2.0 32 | google_fonts: ^2.1.1 33 | 34 | json_annotation: ^4.4.0 35 | # enum_to_string: 1.0.8 36 | copy_with_extension: ^3.0.0 37 | equatable: ^2.0.3 38 | graphql: ^5.0.0 39 | firebase_auth: ^3.3.4 40 | firebase_core: ^1.10.6 41 | google_sign_in: ^5.2.1 42 | formz: ^0.4.1 43 | bot_toast: ^4.0.1 44 | path_provider: ^2.0.8 45 | bloc: ^8.0.1 46 | flutter_bloc: ^8.0.0 47 | hydrated_bloc: ^8.0.0 48 | replay_bloc: ^0.2.1 49 | 50 | # dependency_overrides: 51 | # js: ^0.6.4 52 | # _fe_analyzer_shared: ^32.0.0 53 | # analyzer: ^3.0.0 54 | # coverage: ^1.0.4 55 | # path: ^1.8.1 56 | # platform: ^3.1.0 57 | # rxdart: ^0.27.3 58 | # test: ^1.20.1 59 | # test_api: ^0.4.9 60 | # test_core: ^0.4.11 61 | # vm_service: ^8.1.0 62 | 63 | dev_dependencies: 64 | flutter_test: 65 | sdk: flutter 66 | flutter_driver: 67 | sdk: flutter 68 | 69 | test: ^1.17.11 70 | bloc_test: ^9.0.1 71 | # mockito: ^5.0.17 72 | mocktail: ^0.2.0 73 | 74 | glob: ^2.0.2 75 | gherkin: ^2.0.8 76 | flutter_gherkin: ^2.0.0 77 | 78 | lint: ^1.8.1 79 | # pedantic: ^1.9.0 80 | 81 | build_runner: ^2.1.7 82 | json_serializable: ^6.1.3 83 | copy_with_extension_gen: ^3.0.0 84 | 85 | # For information on the generic Dart part of this file, see the 86 | # following page: https://dart.dev/tools/pub/pubspec 87 | 88 | # The following section is specific to Flutter. 89 | flutter: 90 | # The following line ensures that the Material Icons font is 91 | # included with your application, so that you can use the icons in 92 | # the material Icons class. 93 | uses-material-design: true 94 | # To add assets to your application, add an assets section, like this: 95 | assets: 96 | - assets/ 97 | # - images/a_dot_burr.jpeg 98 | # - images/a_dot_ham.jpeg 99 | # An image asset can refer to one or more resolution-specific "variants", see 100 | # https://flutter.dev/assets-and-images/#resolution-aware. 101 | # For details regarding adding assets from package dependencies, see 102 | # https://flutter.dev/assets-and-images/#from-packages 103 | # To add custom fonts to your application, add a fonts section here, 104 | # in this "flutter" section. Each entry in this list should have a 105 | # "family" key with the font family name, and a "fonts" key with a 106 | # list giving the asset and other descriptors for the font. For 107 | # example: 108 | # fonts: 109 | # - family: Schyler 110 | # fonts: 111 | # - asset: fonts/Schyler-Regular.ttf 112 | # - asset: fonts/Schyler-Italic.ttf 113 | # style: italic 114 | # - family: Trajan Pro 115 | # fonts: 116 | # - asset: fonts/TrajanPro.ttf 117 | # - asset: fonts/TrajanPro_Bold.ttf 118 | # weight: 700 119 | # 120 | # For details regarding fonts from package dependencies, 121 | # see https://flutter.dev/custom-fonts/#from-packages 122 | -------------------------------------------------------------------------------- /test/common/cache_client_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | void main() { 5 | group('CacheClient', () { 6 | test('can write and read a value for a given key', () { 7 | final cache = CacheClient(); 8 | const key = '__key__'; 9 | const value = '__value__'; 10 | expect(cache.read(key: key), isNull); 11 | cache.write(key: key, value: value); 12 | expect(cache.read(key: key), equals(value)); 13 | }); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /test/cubits/authentication_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:mocktail/mocktail.dart'; 4 | import 'package:flutter_idiomatic/import.dart'; 5 | 6 | class MockAuthenticationRepository extends Mock 7 | implements AuthenticationRepository {} 8 | 9 | // ignore: avoid_implementing_value_types 10 | class MockUserModel extends Mock implements UserModel {} 11 | 12 | void main() { 13 | group('AuthenticationState', () { 14 | group('unauthenticated', () { 15 | test('has correct status', () { 16 | final state = AuthenticationState.unauthenticated(); 17 | expect(state.status, AuthenticationStatus.unauthenticated); 18 | expect(state.user, UserModel.empty); 19 | }); 20 | }); 21 | 22 | group('authenticated', () { 23 | test('has correct status', () { 24 | final user = MockUserModel(); 25 | final state = AuthenticationState.authenticated(user); 26 | expect(state.status, AuthenticationStatus.authenticated); 27 | expect(state.user, user); 28 | }); 29 | }); 30 | }); 31 | 32 | group('AuthenticationCubit', () { 33 | final user = MockUserModel(); 34 | late AuthenticationRepository authenticationRepository; 35 | 36 | setUp(() { 37 | authenticationRepository = MockAuthenticationRepository(); 38 | when(() => authenticationRepository.user).thenAnswer( 39 | (_) => Stream.empty(), 40 | ); 41 | when( 42 | () => authenticationRepository.currentUser, 43 | ).thenReturn(UserModel.empty); 44 | }); 45 | 46 | test( 47 | 'initial state is AuthenticationState.unauthenticated when user is empty', 48 | () async { 49 | final authenticationCubit = AuthenticationCubit(authenticationRepository); 50 | expect(authenticationCubit.state, AuthenticationState.unauthenticated()); 51 | await authenticationCubit.close(); 52 | }); 53 | 54 | blocTest( 55 | 'subscribes to user stream', 56 | build: () { 57 | when(() => authenticationRepository.user).thenAnswer( 58 | (_) => Stream.value(user), 59 | ); 60 | return AuthenticationCubit(authenticationRepository); 61 | }, 62 | expect: () => [ 63 | AuthenticationState.authenticated(user), 64 | ], 65 | ); 66 | 67 | group('changeUser', () { 68 | blocTest( 69 | 'emits [authenticated] when user is not empty', 70 | setUp: () { 71 | when(() => user.isNotEmpty).thenReturn(true); 72 | when(() => authenticationRepository.user).thenAnswer( 73 | (_) => Stream.value(user), 74 | ); 75 | }, 76 | build: () => AuthenticationCubit(authenticationRepository), 77 | // act: (bloc) => bloc.changeUser(user), // TODO: ? 78 | seed: () => AuthenticationState.unauthenticated(), 79 | expect: () => [ 80 | AuthenticationState.authenticated(user), 81 | ], 82 | ); 83 | 84 | blocTest( 85 | 'emits [unauthenticated] when user is empty', 86 | setUp: () { 87 | when(() => authenticationRepository.user).thenAnswer( 88 | (_) => Stream.value(UserModel.empty), 89 | ); 90 | }, 91 | build: () => AuthenticationCubit(authenticationRepository), 92 | // act: (bloc) => bloc.changeUser(UserModel.empty), // TODO: ? 93 | expect: () => [ 94 | AuthenticationState.unauthenticated(), 95 | ], 96 | ); 97 | }); 98 | 99 | group('requestLogout', () { 100 | blocTest( 101 | 'calls logOut on authenticationRepository ' 102 | 'when AuthenticationLogoutRequested is added', 103 | setUp: () { 104 | when( 105 | () => authenticationRepository.logOut(), 106 | ).thenAnswer((_) async {}); 107 | }, 108 | build: () => AuthenticationCubit(authenticationRepository), 109 | act: (bloc) => bloc.requestLogout(), 110 | verify: (_) { 111 | verify( 112 | () => authenticationRepository.logOut(), 113 | ).called(1); 114 | }, 115 | ); 116 | }); 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /test/cubits/login_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:formz/formz.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | class MockAuthenticationRepository extends Mock 8 | implements AuthenticationRepository {} 9 | 10 | void main() { 11 | group('LoginState', () { 12 | const emailInput = EmailInputModel.dirty('email'); 13 | const passwordInput = PasswordInputModel.dirty('password'); 14 | 15 | test('supports value comparisons', () { 16 | expect(LoginState(), LoginState()); 17 | }); 18 | 19 | test('returns same object when no properties are passed', () { 20 | expect(LoginState().copyWith(), LoginState()); 21 | }); 22 | 23 | test('returns object with updated status when status is passed', () { 24 | expect( 25 | LoginState().copyWith(status: FormzStatus.pure), 26 | LoginState(), 27 | ); 28 | }); 29 | 30 | test('returns object with updated email when email is passed', () { 31 | expect( 32 | LoginState().copyWith(emailInput: emailInput), 33 | LoginState(emailInput: emailInput), 34 | ); 35 | }); 36 | 37 | test('returns object with updated password when password is passed', () { 38 | expect( 39 | LoginState().copyWith(passwordInput: passwordInput), 40 | LoginState(passwordInput: passwordInput), 41 | ); 42 | }); 43 | }); 44 | 45 | group('LoginCubit', () { 46 | const invalidEmailString = 'invalid'; 47 | const invalidEmail = EmailInputModel.dirty(invalidEmailString); 48 | 49 | const validEmailString = 'test@gmail.com'; 50 | const validEmail = EmailInputModel.dirty(validEmailString); 51 | 52 | const invalidPasswordString = 'invalid'; 53 | const invalidPassword = PasswordInputModel.dirty(invalidPasswordString); 54 | 55 | const validPasswordString = 't0pS3cret1234'; 56 | const validPassword = PasswordInputModel.dirty(validPasswordString); 57 | 58 | late AuthenticationRepository authenticationRepository; 59 | 60 | setUp(() { 61 | authenticationRepository = MockAuthenticationRepository(); 62 | when( 63 | () => authenticationRepository.logInWithGoogle(), 64 | ).thenAnswer((_) async {}); 65 | when( 66 | () => authenticationRepository.logInWithEmailAndPassword( 67 | email: any(named: 'email'), 68 | password: any(named: 'password'), 69 | ), 70 | ).thenAnswer((_) async {}); 71 | }); 72 | 73 | test('initial state is LoginState', () async { 74 | final loginCubit = LoginCubit(authenticationRepository); 75 | expect(loginCubit.state, LoginState()); 76 | await loginCubit.close(); 77 | }); 78 | 79 | group('doEmailChanged', () { 80 | blocTest( 81 | 'emits [invalid] when email/password are invalid', 82 | build: () => LoginCubit(authenticationRepository), 83 | act: (cubit) => cubit.doEmailChanged(invalidEmailString), 84 | expect: () => [ 85 | LoginState(emailInput: invalidEmail, status: FormzStatus.invalid), 86 | ], 87 | ); 88 | 89 | blocTest( 90 | 'emits [valid] when email/password are valid', 91 | build: () => LoginCubit(authenticationRepository), 92 | seed: () => LoginState(passwordInput: validPassword), 93 | act: (cubit) => cubit.doEmailChanged(validEmailString), 94 | expect: () => [ 95 | LoginState( 96 | emailInput: validEmail, 97 | passwordInput: validPassword, 98 | status: FormzStatus.valid, 99 | ), 100 | ], 101 | ); 102 | }); 103 | 104 | group('doPasswordChanged', () { 105 | blocTest( 106 | 'emits [invalid] when email/password are invalid', 107 | build: () => LoginCubit(authenticationRepository), 108 | act: (cubit) => cubit.doPasswordChanged(invalidPasswordString), 109 | expect: () => [ 110 | LoginState( 111 | passwordInput: invalidPassword, 112 | status: FormzStatus.invalid, 113 | ), 114 | ], 115 | ); 116 | 117 | blocTest( 118 | 'emits [valid] when email/password are valid', 119 | build: () => LoginCubit(authenticationRepository), 120 | seed: () => LoginState(emailInput: validEmail), 121 | act: (cubit) => cubit.doPasswordChanged(validPasswordString), 122 | expect: () => [ 123 | LoginState( 124 | emailInput: validEmail, 125 | passwordInput: validPassword, 126 | status: FormzStatus.valid, 127 | ), 128 | ], 129 | ); 130 | }); 131 | 132 | group('logInWithCredentials', () { 133 | blocTest( 134 | 'does nothing when status is not validated', 135 | build: () => LoginCubit(authenticationRepository), 136 | act: (cubit) => cubit.logInWithCredentials(), 137 | expect: () => [], 138 | ); 139 | 140 | blocTest( 141 | 'calls logInWithEmailAndPassword with correct email/password', 142 | build: () => LoginCubit(authenticationRepository), 143 | seed: () => LoginState( 144 | status: FormzStatus.valid, 145 | emailInput: validEmail, 146 | passwordInput: validPassword, 147 | ), 148 | act: (cubit) => cubit.logInWithCredentials(), 149 | verify: (_) { 150 | verify( 151 | () => authenticationRepository.logInWithEmailAndPassword( 152 | email: validEmailString, 153 | password: validPasswordString, 154 | ), 155 | ).called(1); 156 | }, 157 | ); 158 | 159 | blocTest( 160 | 'emits [submissionInProgress, submissionSuccess] ' 161 | 'when logInWithEmailAndPassword succeeds', 162 | build: () => LoginCubit(authenticationRepository), 163 | seed: () => LoginState( 164 | status: FormzStatus.valid, 165 | emailInput: validEmail, 166 | passwordInput: validPassword, 167 | ), 168 | act: (cubit) => cubit.logInWithCredentials(), 169 | expect: () => [ 170 | LoginState( 171 | status: FormzStatus.submissionInProgress, 172 | emailInput: validEmail, 173 | passwordInput: validPassword, 174 | ), 175 | LoginState( 176 | status: FormzStatus.submissionSuccess, 177 | emailInput: validEmail, 178 | passwordInput: validPassword, 179 | ) 180 | ], 181 | ); 182 | 183 | blocTest( 184 | 'emits [submissionInProgress, submissionFailure] ' 185 | 'when logInWithEmailAndPassword fails', 186 | setUp: () { 187 | when( 188 | () => authenticationRepository.logInWithEmailAndPassword( 189 | email: any(named: 'email'), 190 | password: any(named: 'password'), 191 | ), 192 | ).thenThrow(Exception('oops')); 193 | }, 194 | build: () => LoginCubit(authenticationRepository), 195 | seed: () => LoginState( 196 | status: FormzStatus.valid, 197 | emailInput: validEmail, 198 | passwordInput: validPassword, 199 | ), 200 | act: (cubit) => cubit.logInWithCredentials(), 201 | expect: () => [ 202 | LoginState( 203 | status: FormzStatus.submissionInProgress, 204 | emailInput: validEmail, 205 | passwordInput: validPassword, 206 | ), 207 | LoginState( 208 | status: FormzStatus.submissionFailure, 209 | emailInput: validEmail, 210 | passwordInput: validPassword, 211 | ) 212 | ], 213 | ); 214 | }); 215 | 216 | group('logInWithGoogle', () { 217 | blocTest( 218 | 'calls logInWithGoogle', 219 | build: () => LoginCubit(authenticationRepository), 220 | act: (cubit) => cubit.logInWithGoogle(), 221 | verify: (_) { 222 | verify( 223 | () => authenticationRepository.logInWithGoogle(), 224 | ).called(1); 225 | }, 226 | ); 227 | 228 | blocTest( 229 | 'emits [submissionInProgress, submissionSuccess] ' 230 | 'when logInWithGoogle succeeds', 231 | build: () => LoginCubit(authenticationRepository), 232 | act: (cubit) => cubit.logInWithGoogle(), 233 | expect: () => [ 234 | LoginState(status: FormzStatus.submissionInProgress), 235 | LoginState(status: FormzStatus.submissionSuccess) 236 | ], 237 | ); 238 | 239 | blocTest( 240 | 'emits [submissionInProgress, submissionFailure] ' 241 | 'when logInWithGoogle fails', 242 | setUp: () { 243 | when( 244 | () => authenticationRepository.logInWithGoogle(), 245 | ).thenThrow(Exception('oops')); 246 | }, 247 | build: () => LoginCubit(authenticationRepository), 248 | act: (cubit) => cubit.logInWithGoogle(), 249 | expect: () => [ 250 | LoginState(status: FormzStatus.submissionInProgress), 251 | LoginState(status: FormzStatus.submissionFailure) 252 | ], 253 | ); 254 | }); 255 | }); 256 | } 257 | -------------------------------------------------------------------------------- /test/cubits/sign_up_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:formz/formz.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | class MockAuthenticationRepository extends Mock 8 | implements AuthenticationRepository {} 9 | 10 | void main() { 11 | group('SignUpState', () { 12 | const emailInput = EmailInputModel.dirty('email'); 13 | const passwordString = 'password'; 14 | const passwordInput = PasswordInputModel.dirty(passwordString); 15 | const confirmedPassword = ConfirmedPasswordInputModel.dirty( 16 | password: passwordString, 17 | value: passwordString, 18 | ); 19 | 20 | test('supports value comparisons', () { 21 | expect(SignUpState(), SignUpState()); 22 | }); 23 | 24 | test('returns same object when no properties are passed', () { 25 | expect(SignUpState().copyWith(), SignUpState()); 26 | }); 27 | 28 | test('returns object with updated status when status is passed', () { 29 | expect( 30 | SignUpState().copyWith(status: FormzStatus.pure), 31 | SignUpState(), 32 | ); 33 | }); 34 | 35 | test('returns object with updated email when email is passed', () { 36 | expect( 37 | SignUpState().copyWith(emailInput: emailInput), 38 | SignUpState(emailInput: emailInput), 39 | ); 40 | }); 41 | 42 | test('returns object with updated password when password is passed', () { 43 | expect( 44 | SignUpState().copyWith(passwordInput: passwordInput), 45 | SignUpState(passwordInput: passwordInput), 46 | ); 47 | }); 48 | 49 | test( 50 | 'returns object with updated confirmedPassword' 51 | ' when confirmedPassword is passed', () { 52 | expect( 53 | SignUpState().copyWith(confirmedPasswordInput: confirmedPassword), 54 | SignUpState(confirmedPasswordInput: confirmedPassword), 55 | ); 56 | }); 57 | }); 58 | 59 | group('SignUpCubit', () { 60 | const invalidEmailString = 'invalid'; 61 | const invalidEmail = EmailInputModel.dirty(invalidEmailString); 62 | 63 | const validEmailString = 'test@gmail.com'; 64 | const validEmail = EmailInputModel.dirty(validEmailString); 65 | 66 | const invalidPasswordString = 'invalid'; 67 | const invalidPassword = PasswordInputModel.dirty(invalidPasswordString); 68 | 69 | const validPasswordString = 't0pS3cret1234'; 70 | const validPassword = PasswordInputModel.dirty(validPasswordString); 71 | 72 | const invalidConfirmedPasswordString = 'invalid'; 73 | const invalidConfirmedPassword = ConfirmedPasswordInputModel.dirty( 74 | password: validPasswordString, 75 | value: invalidConfirmedPasswordString, 76 | ); 77 | 78 | const validConfirmedPasswordString = 't0pS3cret1234'; 79 | const validConfirmedPassword = ConfirmedPasswordInputModel.dirty( 80 | password: validPasswordString, 81 | value: validConfirmedPasswordString, 82 | ); 83 | 84 | late AuthenticationRepository authenticationRepository; 85 | 86 | setUp(() { 87 | authenticationRepository = MockAuthenticationRepository(); 88 | when( 89 | () => authenticationRepository.signUp( 90 | email: any(named: 'email'), 91 | password: any(named: 'password'), 92 | ), 93 | ).thenAnswer((_) async {}); 94 | }); 95 | 96 | test('initial state is SignUpState', () async { 97 | final signUpCubit = SignUpCubit(authenticationRepository); 98 | expect(signUpCubit.state, SignUpState()); 99 | await signUpCubit.close(); 100 | }); 101 | 102 | group('doEmailChanged', () { 103 | blocTest( 104 | 'emits [invalid] when email/password/confirmedPassword are invalid', 105 | build: () => SignUpCubit(authenticationRepository), 106 | act: (cubit) => cubit.doEmailChanged(invalidEmailString), 107 | expect: () => [ 108 | SignUpState(emailInput: invalidEmail, status: FormzStatus.invalid), 109 | ], 110 | ); 111 | 112 | blocTest( 113 | 'emits [valid] when email/password/confirmedPassword are valid', 114 | build: () => SignUpCubit(authenticationRepository), 115 | seed: () => SignUpState( 116 | passwordInput: validPassword, 117 | confirmedPasswordInput: validConfirmedPassword, 118 | ), 119 | act: (cubit) => cubit.doEmailChanged(validEmailString), 120 | expect: () => [ 121 | SignUpState( 122 | emailInput: validEmail, 123 | passwordInput: validPassword, 124 | confirmedPasswordInput: validConfirmedPassword, 125 | status: FormzStatus.valid, 126 | ), 127 | ], 128 | ); 129 | }); 130 | 131 | group('doPasswordChanged', () { 132 | blocTest( 133 | 'emits [invalid] when email/password/confirmedPassword are invalid', 134 | build: () => SignUpCubit(authenticationRepository), 135 | act: (cubit) => cubit.doPasswordChanged(invalidPasswordString), 136 | expect: () => [ 137 | SignUpState( 138 | confirmedPasswordInput: ConfirmedPasswordInputModel.dirty( 139 | password: invalidPasswordString, 140 | ), 141 | passwordInput: invalidPassword, 142 | status: FormzStatus.invalid, 143 | ), 144 | ], 145 | ); 146 | 147 | blocTest( 148 | 'emits [valid] when email/password/confirmedPassword are valid', 149 | build: () => SignUpCubit(authenticationRepository), 150 | seed: () => SignUpState( 151 | emailInput: validEmail, 152 | confirmedPasswordInput: validConfirmedPassword, 153 | ), 154 | act: (cubit) => cubit.doPasswordChanged(validPasswordString), 155 | expect: () => [ 156 | SignUpState( 157 | emailInput: validEmail, 158 | passwordInput: validPassword, 159 | confirmedPasswordInput: validConfirmedPassword, 160 | status: FormzStatus.valid, 161 | ), 162 | ], 163 | ); 164 | 165 | blocTest( 166 | 'emits [valid] when confirmedPasswordChanged is called first and then ' 167 | 'passwordChanged is called', 168 | build: () => SignUpCubit(authenticationRepository), 169 | seed: () => SignUpState( 170 | emailInput: validEmail, 171 | ), 172 | act: (cubit) => cubit 173 | ..doConfirmedPasswordChanged(validConfirmedPasswordString) 174 | ..doPasswordChanged(validPasswordString), 175 | expect: () => const [ 176 | SignUpState( 177 | emailInput: validEmail, 178 | confirmedPasswordInput: validConfirmedPassword, 179 | status: FormzStatus.invalid, 180 | ), 181 | SignUpState( 182 | emailInput: validEmail, 183 | passwordInput: validPassword, 184 | confirmedPasswordInput: validConfirmedPassword, 185 | status: FormzStatus.valid, 186 | ), 187 | ], 188 | ); 189 | }); 190 | 191 | group('doConfirmedPasswordChanged', () { 192 | blocTest( 193 | 'emits [invalid] when email/password/confirmedPassword are invalid', 194 | build: () => SignUpCubit(authenticationRepository), 195 | act: (cubit) => 196 | cubit.doConfirmedPasswordChanged(invalidConfirmedPasswordString), 197 | expect: () => [ 198 | SignUpState( 199 | confirmedPasswordInput: invalidConfirmedPassword, 200 | status: FormzStatus.invalid, 201 | ), 202 | ], 203 | ); 204 | 205 | blocTest( 206 | 'emits [valid] when email/password/confirmedPassword are valid', 207 | build: () => SignUpCubit(authenticationRepository), 208 | seed: () => 209 | SignUpState(emailInput: validEmail, passwordInput: validPassword), 210 | act: (cubit) => cubit.doConfirmedPasswordChanged( 211 | validConfirmedPasswordString, 212 | ), 213 | expect: () => [ 214 | SignUpState( 215 | emailInput: validEmail, 216 | passwordInput: validPassword, 217 | confirmedPasswordInput: validConfirmedPassword, 218 | status: FormzStatus.valid, 219 | ), 220 | ], 221 | ); 222 | 223 | blocTest( 224 | 'emits [valid] when passwordChanged is called first and then ' 225 | 'confirmedPasswordChanged is called', 226 | build: () => SignUpCubit(authenticationRepository), 227 | seed: () => SignUpState(emailInput: validEmail), 228 | act: (cubit) => cubit 229 | ..doPasswordChanged(validPasswordString) 230 | ..doConfirmedPasswordChanged(validConfirmedPasswordString), 231 | expect: () => const [ 232 | SignUpState( 233 | emailInput: validEmail, 234 | passwordInput: validPassword, 235 | confirmedPasswordInput: ConfirmedPasswordInputModel.dirty( 236 | password: validPasswordString, 237 | ), 238 | status: FormzStatus.invalid, 239 | ), 240 | SignUpState( 241 | emailInput: validEmail, 242 | passwordInput: validPassword, 243 | confirmedPasswordInput: validConfirmedPassword, 244 | status: FormzStatus.valid, 245 | ), 246 | ], 247 | ); 248 | }); 249 | 250 | group('signUpFormSubmitted', () { 251 | blocTest( 252 | 'does nothing when status is not validated', 253 | build: () => SignUpCubit(authenticationRepository), 254 | act: (cubit) => cubit.signUpFormSubmitted(), 255 | expect: () => [], 256 | ); 257 | 258 | blocTest( 259 | 'calls signUp with correct email/password/confirmedPassword', 260 | build: () => SignUpCubit(authenticationRepository), 261 | seed: () => SignUpState( 262 | status: FormzStatus.valid, 263 | emailInput: validEmail, 264 | passwordInput: validPassword, 265 | confirmedPasswordInput: validConfirmedPassword, 266 | ), 267 | act: (cubit) => cubit.signUpFormSubmitted(), 268 | verify: (_) { 269 | verify( 270 | () => authenticationRepository.signUp( 271 | email: validEmailString, 272 | password: validPasswordString, 273 | ), 274 | ).called(1); 275 | }, 276 | ); 277 | 278 | blocTest( 279 | 'emits [submissionInProgress, submissionSuccess] ' 280 | 'when signUp succeeds', 281 | build: () => SignUpCubit(authenticationRepository), 282 | seed: () => SignUpState( 283 | status: FormzStatus.valid, 284 | emailInput: validEmail, 285 | passwordInput: validPassword, 286 | confirmedPasswordInput: validConfirmedPassword, 287 | ), 288 | act: (cubit) => cubit.signUpFormSubmitted(), 289 | expect: () => [ 290 | SignUpState( 291 | status: FormzStatus.submissionInProgress, 292 | emailInput: validEmail, 293 | passwordInput: validPassword, 294 | confirmedPasswordInput: validConfirmedPassword, 295 | ), 296 | SignUpState( 297 | status: FormzStatus.submissionSuccess, 298 | emailInput: validEmail, 299 | passwordInput: validPassword, 300 | confirmedPasswordInput: validConfirmedPassword, 301 | ) 302 | ], 303 | ); 304 | 305 | blocTest( 306 | 'emits [submissionInProgress, submissionFailure] ' 307 | 'when signUp fails', 308 | setUp: () { 309 | when(() => authenticationRepository.signUp( 310 | email: any(named: 'email'), 311 | password: any(named: 'password'), 312 | )).thenThrow(Exception('oops')); 313 | }, 314 | build: () => SignUpCubit(authenticationRepository), 315 | seed: () => SignUpState( 316 | status: FormzStatus.valid, 317 | emailInput: validEmail, 318 | passwordInput: validPassword, 319 | confirmedPasswordInput: validConfirmedPassword, 320 | ), 321 | act: (cubit) => cubit.signUpFormSubmitted(), 322 | expect: () => [ 323 | SignUpState( 324 | status: FormzStatus.submissionInProgress, 325 | emailInput: validEmail, 326 | passwordInput: validPassword, 327 | confirmedPasswordInput: validConfirmedPassword, 328 | ), 329 | SignUpState( 330 | status: FormzStatus.submissionFailure, 331 | emailInput: validEmail, 332 | passwordInput: validPassword, 333 | confirmedPasswordInput: validConfirmedPassword, 334 | ) 335 | ], 336 | ); 337 | }); 338 | }); 339 | } 340 | -------------------------------------------------------------------------------- /test/main_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:bloc_test/bloc_test.dart'; 2 | import 'package:flutter_bloc/flutter_bloc.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | import 'package:flutter_idiomatic/import.dart'; 6 | 7 | class MockUser extends Mock implements UserModel {} 8 | 9 | class MockAuthenticationRepository extends Mock 10 | implements AuthenticationRepository {} 11 | 12 | class MockAuthenticationCubit extends MockCubit 13 | implements AuthenticationCubit {} 14 | 15 | class MockGitHubRepository extends Mock implements GitHubRepository {} 16 | 17 | class MockDatabaseRepository extends Mock implements DatabaseRepository {} 18 | 19 | void main() { 20 | group('App', () { 21 | late AuthenticationRepository authenticationRepository; 22 | late UserModel user; 23 | late GitHubRepository gitHubRepository; 24 | late DatabaseRepository databaseRepository; 25 | setUp(() { 26 | authenticationRepository = MockAuthenticationRepository(); 27 | user = MockUser(); 28 | gitHubRepository = MockGitHubRepository(); 29 | databaseRepository = MockDatabaseRepository(); 30 | when(() => authenticationRepository.user).thenAnswer( 31 | (_) => Stream.empty(), 32 | ); 33 | when(() => authenticationRepository.currentUser).thenReturn(user); 34 | when(() => user.isNotEmpty).thenReturn(true); 35 | when(() => user.isEmpty).thenReturn(false); 36 | when(() => user.email).thenReturn('test@gmail.com'); 37 | }); 38 | testWidgets('renders AppView', (tester) async { 39 | await tester.pumpWidget( 40 | App( 41 | authenticationRepository: authenticationRepository, 42 | gitHubRepository: gitHubRepository, 43 | databaseRepository: databaseRepository, 44 | ), 45 | ); 46 | await tester.pump(); 47 | expect(find.byType(AppView), findsOneWidget); 48 | }); 49 | }); 50 | group('AppView', () { 51 | late AuthenticationRepository authenticationRepository; 52 | late AuthenticationCubit authenticationCubit; 53 | setUp(() { 54 | authenticationRepository = MockAuthenticationRepository(); 55 | authenticationCubit = MockAuthenticationCubit(); 56 | }); 57 | testWidgets('renders SplashScreen by default', (tester) async { 58 | await tester.pumpWidget( 59 | BlocProvider.value(value: authenticationCubit, child: AppView()), 60 | ); 61 | await tester.pumpAndSettle(); 62 | expect(find.byType(SplashScreen), findsOneWidget); 63 | }); 64 | testWidgets('navigates to LoginScreen when unauthenticated', 65 | (tester) async { 66 | when(() => authenticationCubit.state) 67 | .thenReturn(AuthenticationState.unauthenticated()); 68 | await tester.pumpWidget( 69 | RepositoryProvider.value( 70 | value: authenticationRepository, 71 | child: BlocProvider.value( 72 | value: authenticationCubit, 73 | child: AppView(), 74 | ), 75 | ), 76 | ); 77 | await tester.pumpAndSettle(); 78 | expect(find.byType(LoginScreen), findsOneWidget); 79 | }); 80 | testWidgets('navigates to HomeScreen when authenticated', (tester) async { 81 | final user = MockUser(); 82 | when(() => user.email).thenReturn('test@gmail.com'); 83 | when(() => authenticationCubit.state) 84 | .thenReturn(AuthenticationState.authenticated(user)); 85 | 86 | await tester.pumpWidget( 87 | RepositoryProvider.value( 88 | value: authenticationRepository, 89 | child: BlocProvider.value( 90 | value: authenticationCubit, 91 | child: AppView(), 92 | ), 93 | ), 94 | ); 95 | await tester.pumpAndSettle(); 96 | expect(find.byType(HomeScreen), findsOneWidget); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /test/models/confirmed_password_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | void main() { 5 | const confirmedPasswordString = 'T0pS3cr3t123'; 6 | const passwordString = 'T0pS3cr3t123'; 7 | const password = PasswordInputModel.dirty(passwordString); 8 | group('confirmedPassword', () { 9 | group('constructors', () { 10 | test('pure creates correct instance', () { 11 | final confirmedPassword = ConfirmedPasswordInputModel.pure(); 12 | expect(confirmedPassword.value, ''); 13 | expect(confirmedPassword.pure, true); 14 | }); 15 | 16 | test('dirty creates correct instance', () { 17 | final confirmedPassword = ConfirmedPasswordInputModel.dirty( 18 | password: password.value, 19 | value: confirmedPasswordString, 20 | ); 21 | expect(confirmedPassword.value, confirmedPasswordString); 22 | expect(confirmedPassword.password, password.value); 23 | expect(confirmedPassword.pure, false); 24 | }); 25 | }); 26 | 27 | group('validator', () { 28 | test('returns invalid error when confirmedPassword is empty', () { 29 | expect( 30 | ConfirmedPasswordInputModel.dirty(password: password.value).error, 31 | ConfirmedPasswordInputValidationError.invalid, 32 | ); 33 | }); 34 | 35 | test('is valid when confirmedPassword is not empty', () { 36 | expect( 37 | ConfirmedPasswordInputModel.dirty( 38 | password: password.value, 39 | value: confirmedPasswordString, 40 | ).error, 41 | isNull, 42 | ); 43 | }); 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /test/models/email_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | void main() { 5 | const emailString = 'test@gmail.com'; 6 | group('Email', () { 7 | group('constructors', () { 8 | test('pure creates correct instance', () { 9 | final email = EmailInputModel.pure(); 10 | expect(email.value, ''); 11 | expect(email.pure, true); 12 | }); 13 | 14 | test('dirty creates correct instance', () { 15 | final email = EmailInputModel.dirty(emailString); 16 | expect(email.value, emailString); 17 | expect(email.pure, false); 18 | }); 19 | }); 20 | 21 | group('validator', () { 22 | test('returns invalid error when email is empty', () { 23 | expect( 24 | EmailInputModel.dirty().error, 25 | EmailInputValidationError.invalid, 26 | ); 27 | }); 28 | 29 | test('returns invalid error when email is malformed', () { 30 | expect( 31 | EmailInputModel.dirty('test').error, 32 | EmailInputValidationError.invalid, 33 | ); 34 | }); 35 | 36 | test('is valid when email is valid', () { 37 | expect( 38 | EmailInputModel.dirty(emailString).error, 39 | isNull, 40 | ); 41 | }); 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /test/models/password_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | void main() { 5 | const passwordString = 'T0pS3cr3t123'; 6 | 7 | group('Password', () { 8 | group('constructors', () { 9 | test('pure creates correct instance', () { 10 | final password = PasswordInputModel.pure(); 11 | expect(password.value, ''); 12 | expect(password.pure, true); 13 | }); 14 | 15 | test('dirty creates correct instance', () { 16 | final password = PasswordInputModel.dirty(passwordString); 17 | expect(password.value, passwordString); 18 | expect(password.pure, false); 19 | }); 20 | }); 21 | 22 | group('validator', () { 23 | test('returns invalid error when password is empty', () { 24 | expect( 25 | PasswordInputModel.dirty().error, 26 | PasswordInputValidationError.invalid, 27 | ); 28 | }); 29 | 30 | test('is valid when password is not empty', () { 31 | expect( 32 | PasswordInputModel.dirty(passwordString).error, 33 | isNull, 34 | ); 35 | }); 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /test/models/user_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_test/flutter_test.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | void main() { 5 | group('User', () { 6 | const id = 'mock-id'; 7 | const email = 'mock-email'; 8 | 9 | test('uses value equality', () { 10 | expect( 11 | UserModel(email: email, id: id), 12 | equals(UserModel(email: email, id: id)), 13 | ); 14 | }); 15 | 16 | test('isEmpty returns true for empty user', () { 17 | expect(UserModel.empty.isEmpty, isTrue); 18 | }); 19 | 20 | test('isEmpty returns false for non-empty user', () { 21 | final user = UserModel(email: email, id: id); 22 | expect(user.isEmpty, isFalse); 23 | }); 24 | 25 | test('isNotEmpty returns false for empty user', () { 26 | expect(UserModel.empty.isNotEmpty, isFalse); 27 | }); 28 | 29 | test('isNotEmpty returns true for non-empty user', () { 30 | final user = UserModel(email: email, id: id); 31 | expect(user.isNotEmpty, isTrue); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /test/screens/home_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:bloc_test/bloc_test.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:mocktail/mocktail.dart'; 6 | import 'package:flutter_idiomatic/import.dart'; 7 | 8 | class MockAuthenticationCubit extends MockCubit 9 | implements AuthenticationCubit {} 10 | 11 | // ignore: avoid_implementing_value_types 12 | class MockUserModel extends Mock implements UserModel {} 13 | 14 | void main() { 15 | group('HomeScreen', () { 16 | late AuthenticationCubit authenticationCubit; 17 | late UserModel user; 18 | 19 | setUp(() { 20 | authenticationCubit = MockAuthenticationCubit(); 21 | user = MockUserModel(); 22 | when(() => user.email).thenReturn('test@gmail.com'); 23 | when(() => authenticationCubit.state) 24 | .thenReturn(AuthenticationState.authenticated(user)); 25 | }); 26 | 27 | test('has a route', () { 28 | expect(HomeScreen().getRoute(), isA()); 29 | }); 30 | 31 | group('calls', () { 32 | testWidgets('AuthenticationLogoutRequested when logout is pressed', 33 | (tester) async { 34 | await tester.pumpWidget( 35 | BlocProvider.value( 36 | value: authenticationCubit, 37 | child: MaterialApp( 38 | home: HomeScreen(), 39 | ), 40 | ), 41 | ); 42 | await tester.tap(find.byKey(Key('_LogoutButton'))); 43 | verify(() => authenticationCubit.requestLogout()).called(1); 44 | }); 45 | }); 46 | 47 | group('renders', () { 48 | testWidgets('avatar widget', (tester) async { 49 | await tester.pumpWidget( 50 | BlocProvider.value( 51 | value: authenticationCubit, 52 | child: MaterialApp( 53 | home: HomeScreen(), 54 | ), 55 | ), 56 | ); 57 | expect(find.byType(Avatar), findsOneWidget); 58 | }); 59 | 60 | testWidgets('email address', (tester) async { 61 | await tester.pumpWidget( 62 | BlocProvider.value( 63 | value: authenticationCubit, 64 | child: MaterialApp( 65 | home: HomeScreen(), 66 | ), 67 | ), 68 | ); 69 | expect(find.text('test@gmail.com'), findsOneWidget); 70 | }); 71 | 72 | testWidgets('name', (tester) async { 73 | when(() => user.name).thenReturn('Joe'); 74 | await tester.pumpWidget( 75 | BlocProvider.value( 76 | value: authenticationCubit, 77 | child: MaterialApp( 78 | home: HomeScreen(), 79 | ), 80 | ), 81 | ); 82 | expect(find.text('Joe'), findsOneWidget); 83 | }); 84 | }); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /test/screens/login_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:bloc_test/bloc_test.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:formz/formz.dart'; 6 | import 'package:mocktail/mocktail.dart'; 7 | import 'package:flutter_idiomatic/import.dart'; 8 | 9 | class MockAuthenticationRepository extends Mock 10 | implements AuthenticationRepository {} 11 | 12 | class MockLoginCubit extends MockCubit implements LoginCubit {} 13 | 14 | // ignore: avoid_implementing_value_types 15 | class MockEmailInputModel extends Mock implements EmailInputModel {} 16 | 17 | // ignore: avoid_implementing_value_types 18 | class MockPasswordInputModel extends Mock implements PasswordInputModel {} 19 | 20 | void main() { 21 | group('LoginScreen', () { 22 | test('has a route', () { 23 | expect(LoginScreen().getRoute(), isA()); 24 | }); 25 | 26 | testWidgets('renders a LoginForm', (tester) async { 27 | await tester.pumpWidget( 28 | RepositoryProvider.value( 29 | value: MockAuthenticationRepository(), 30 | child: MaterialApp(home: LoginScreen()), 31 | ), 32 | ); 33 | expect(find.byType(LoginForm), findsOneWidget); 34 | }); 35 | }); 36 | 37 | const testEmail = 'test@gmail.com'; 38 | const testPassword = 'testP@ssw0rd1'; 39 | 40 | group('LoginForm', () { 41 | late LoginCubit loginCubit; 42 | 43 | setUp(() { 44 | loginCubit = MockLoginCubit(); 45 | when(() => loginCubit.state).thenReturn(LoginState()); 46 | when(() => loginCubit.logInWithGoogle()).thenAnswer((_) async {}); 47 | when(() => loginCubit.logInWithCredentials()).thenAnswer((_) async {}); 48 | }); 49 | 50 | group('calls', () { 51 | testWidgets('doEmailChanged when email changes', (tester) async { 52 | await tester.pumpWidget( 53 | MaterialApp( 54 | home: Scaffold( 55 | body: BlocProvider.value( 56 | value: loginCubit, 57 | child: LoginForm(), 58 | ), 59 | ), 60 | ), 61 | ); 62 | await tester.enterText(find.byKey(Key('_EmailInput')), testEmail); 63 | verify(() => loginCubit.doEmailChanged(testEmail)).called(1); 64 | }); 65 | 66 | testWidgets('doPasswordChanged when password changes', (tester) async { 67 | await tester.pumpWidget( 68 | MaterialApp( 69 | home: Scaffold( 70 | body: BlocProvider.value( 71 | value: loginCubit, 72 | child: LoginForm(), 73 | ), 74 | ), 75 | ), 76 | ); 77 | await tester.enterText(find.byKey(Key('_PasswordInput')), testPassword); 78 | verify(() => loginCubit.doPasswordChanged(testPassword)).called(1); 79 | }); 80 | 81 | testWidgets('logInWithCredentials when login button is pressed', 82 | (tester) async { 83 | when(() => loginCubit.state).thenReturn( 84 | LoginState(status: FormzStatus.valid), 85 | ); 86 | await tester.pumpWidget( 87 | MaterialApp( 88 | home: Scaffold( 89 | body: BlocProvider.value( 90 | value: loginCubit, 91 | child: LoginForm(), 92 | ), 93 | ), 94 | ), 95 | ); 96 | await tester.tap(find.byKey(Key('_LoginButton'))); 97 | verify(() => loginCubit.logInWithCredentials()).called(1); 98 | }); 99 | 100 | testWidgets('logInWithGoogle when sign in with google button is pressed', 101 | (tester) async { 102 | await tester.pumpWidget( 103 | MaterialApp( 104 | home: Scaffold( 105 | body: BlocProvider.value( 106 | value: loginCubit, 107 | child: LoginForm(), 108 | ), 109 | ), 110 | ), 111 | ); 112 | await tester.tap(find.byKey(Key('_GoogleLoginButton'))); 113 | verify(() => loginCubit.logInWithGoogle()).called(1); 114 | }); 115 | }); 116 | 117 | group('renders', () { 118 | testWidgets('AuthenticationFailure SnackBar when submission fails', 119 | (tester) async { 120 | whenListen( 121 | loginCubit, 122 | Stream.fromIterable([ 123 | LoginState(status: FormzStatus.submissionInProgress), 124 | LoginState(status: FormzStatus.submissionFailure), 125 | ]), 126 | ); 127 | await tester.pumpWidget( 128 | MaterialApp( 129 | home: Scaffold( 130 | body: BlocProvider.value( 131 | value: loginCubit, 132 | child: LoginForm(), 133 | ), 134 | ), 135 | ), 136 | ); 137 | await tester.pump(); 138 | expect(find.text('Authentication Failure'), findsOneWidget); 139 | }); 140 | 141 | testWidgets('invalid email error text when email is invalid', 142 | (tester) async { 143 | final emailInput = MockEmailInputModel(); 144 | when(() => emailInput.invalid).thenReturn(true); 145 | when(() => loginCubit.state) 146 | .thenReturn(LoginState(emailInput: emailInput)); 147 | await tester.pumpWidget( 148 | MaterialApp( 149 | home: Scaffold( 150 | body: BlocProvider.value( 151 | value: loginCubit, 152 | child: LoginForm(), 153 | ), 154 | ), 155 | ), 156 | ); 157 | expect(find.text('invalid email'), findsOneWidget); 158 | }); 159 | 160 | testWidgets('invalid password error text when password is invalid', 161 | (tester) async { 162 | final passwordInput = MockPasswordInputModel(); 163 | when(() => passwordInput.invalid).thenReturn(true); 164 | when(() => loginCubit.state) 165 | .thenReturn(LoginState(passwordInput: passwordInput)); 166 | await tester.pumpWidget( 167 | MaterialApp( 168 | home: Scaffold( 169 | body: BlocProvider.value( 170 | value: loginCubit, 171 | child: LoginForm(), 172 | ), 173 | ), 174 | ), 175 | ); 176 | expect(find.text('invalid password'), findsOneWidget); 177 | }); 178 | 179 | testWidgets('disabled login button when status is not validated', 180 | (tester) async { 181 | when(() => loginCubit.state).thenReturn( 182 | LoginState(status: FormzStatus.invalid), 183 | ); 184 | await tester.pumpWidget( 185 | MaterialApp( 186 | home: Scaffold( 187 | body: BlocProvider.value( 188 | value: loginCubit, 189 | child: LoginForm(), 190 | ), 191 | ), 192 | ), 193 | ); 194 | final loginButton = tester.widget( 195 | find.byKey(Key('_LoginButton')), 196 | ); 197 | expect(loginButton.enabled, isFalse); 198 | }); 199 | 200 | testWidgets('enabled login button when status is validated', 201 | (tester) async { 202 | when(() => loginCubit.state).thenReturn( 203 | LoginState(status: FormzStatus.valid), 204 | ); 205 | await tester.pumpWidget( 206 | MaterialApp( 207 | home: Scaffold( 208 | body: BlocProvider.value( 209 | value: loginCubit, 210 | child: LoginForm(), 211 | ), 212 | ), 213 | ), 214 | ); 215 | final loginButton = tester.widget( 216 | find.byKey(Key('_LoginButton')), 217 | ); 218 | expect(loginButton.enabled, isTrue); 219 | }); 220 | 221 | testWidgets('Sign in with Google Button', (tester) async { 222 | await tester.pumpWidget( 223 | MaterialApp( 224 | home: Scaffold( 225 | body: BlocProvider.value( 226 | value: loginCubit, 227 | child: LoginForm(), 228 | ), 229 | ), 230 | ), 231 | ); 232 | expect(find.byKey(Key('_GoogleLoginButton')), findsOneWidget); 233 | }); 234 | }); 235 | 236 | group('navigates', () { 237 | testWidgets('to SignUpScreen when Create Account is pressed', 238 | (tester) async { 239 | await tester.pumpWidget( 240 | RepositoryProvider( 241 | create: (_) => MockAuthenticationRepository(), 242 | child: MaterialApp( 243 | navigatorKey: navigatorKey, 244 | home: Scaffold( 245 | body: BlocProvider.value( 246 | value: loginCubit, 247 | child: LoginForm(), 248 | ), 249 | ), 250 | ), 251 | ), 252 | ); 253 | await tester.tap(find.byKey(Key('_SignUpButton'))); 254 | await tester.pumpAndSettle(); 255 | expect(find.byType(SignUpScreen), findsOneWidget); 256 | }); 257 | }); 258 | }); 259 | } 260 | -------------------------------------------------------------------------------- /test/screens/sign_up_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:bloc_test/bloc_test.dart'; 3 | import 'package:flutter_bloc/flutter_bloc.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:formz/formz.dart'; 6 | import 'package:mocktail/mocktail.dart'; 7 | import 'package:flutter_idiomatic/import.dart'; 8 | 9 | class MockAuthenticationRepository extends Mock 10 | implements AuthenticationRepository {} 11 | 12 | class MockSignUpCubit extends MockCubit implements SignUpCubit {} 13 | 14 | // ignore: avoid_implementing_value_types 15 | class MockEmailInputModel extends Mock implements EmailInputModel {} 16 | 17 | // ignore: avoid_implementing_value_types 18 | class MockPasswordInputModel extends Mock implements PasswordInputModel {} 19 | 20 | class MockConfirmedPasswordInputModel extends Mock 21 | implements 22 | // ignore: avoid_implementing_value_types 23 | ConfirmedPasswordInputModel {} 24 | 25 | void main() { 26 | group('SignUpScreen', () { 27 | test('has a route', () { 28 | expect(SignUpScreen().getRoute(), isA()); 29 | }); 30 | 31 | testWidgets('renders a SignUpForm', (tester) async { 32 | await tester.pumpWidget( 33 | RepositoryProvider( 34 | create: (_) => MockAuthenticationRepository(), 35 | child: MaterialApp(home: SignUpScreen()), 36 | ), 37 | ); 38 | expect(find.byType(SignUpForm), findsOneWidget); 39 | }); 40 | }); 41 | 42 | group('SignUpForm', () { 43 | const testEmail = 'test@gmail.com'; 44 | const testPassword = 'testP@ssw0rd1'; 45 | const testConfirmedPassword = 'testP@ssw0rd1'; 46 | 47 | late SignUpCubit signUpCubit; 48 | 49 | setUp(() { 50 | signUpCubit = MockSignUpCubit(); 51 | when(() => signUpCubit.state).thenReturn(SignUpState()); 52 | when(() => signUpCubit.signUpFormSubmitted()).thenAnswer((_) async {}); 53 | }); 54 | 55 | group('calls', () { 56 | testWidgets('doEmailChanged when email changes', (tester) async { 57 | await tester.pumpWidget( 58 | MaterialApp( 59 | home: Scaffold( 60 | body: BlocProvider.value( 61 | value: signUpCubit, 62 | child: SignUpForm(), 63 | ), 64 | ), 65 | ), 66 | ); 67 | await tester.enterText(find.byKey(Key('_EmailInput')), testEmail); 68 | verify(() => signUpCubit.doEmailChanged(testEmail)).called(1); 69 | }); 70 | 71 | testWidgets('doPasswordChanged when password changes', (tester) async { 72 | await tester.pumpWidget( 73 | MaterialApp( 74 | home: Scaffold( 75 | body: BlocProvider.value( 76 | value: signUpCubit, 77 | child: SignUpForm(), 78 | ), 79 | ), 80 | ), 81 | ); 82 | await tester.enterText(find.byKey(Key('_PasswordInput')), testPassword); 83 | verify(() => signUpCubit.doPasswordChanged(testPassword)).called(1); 84 | }); 85 | 86 | testWidgets('doConfirmedPasswordChanged when confirmedPassword changes', 87 | (tester) async { 88 | await tester.pumpWidget( 89 | MaterialApp( 90 | home: Scaffold( 91 | body: BlocProvider.value( 92 | value: signUpCubit, 93 | child: SignUpForm(), 94 | ), 95 | ), 96 | ), 97 | ); 98 | await tester.enterText( 99 | find.byKey(Key('_ConfirmPasswordInput')), 100 | testConfirmedPassword, 101 | ); 102 | verify( 103 | () => signUpCubit.doConfirmedPasswordChanged(testConfirmedPassword), 104 | ).called(1); 105 | }); 106 | 107 | testWidgets('signUpFormSubmitted when sign up button is pressed', 108 | (tester) async { 109 | when(() => signUpCubit.state).thenReturn( 110 | SignUpState(status: FormzStatus.valid), 111 | ); 112 | await tester.pumpWidget( 113 | MaterialApp( 114 | home: Scaffold( 115 | body: BlocProvider.value( 116 | value: signUpCubit, 117 | child: SignUpForm(), 118 | ), 119 | ), 120 | ), 121 | ); 122 | await tester.tap(find.byKey(Key('_SignUpButton'))); 123 | verify(() => signUpCubit.signUpFormSubmitted()).called(1); 124 | }); 125 | }); 126 | 127 | group('renders', () { 128 | testWidgets('Sign Up Failure SnackBar when submission fails', 129 | (tester) async { 130 | whenListen( 131 | signUpCubit, 132 | Stream.fromIterable([ 133 | SignUpState(status: FormzStatus.submissionInProgress), 134 | SignUpState(status: FormzStatus.submissionFailure), 135 | ]), 136 | ); 137 | await tester.pumpWidget( 138 | MaterialApp( 139 | home: Scaffold( 140 | body: BlocProvider.value( 141 | value: signUpCubit, 142 | child: SignUpForm(), 143 | ), 144 | ), 145 | ), 146 | ); 147 | await tester.pump(); 148 | expect(find.text('Sign Up Failure'), findsOneWidget); 149 | }); 150 | 151 | testWidgets('invalid email error text when email is invalid', 152 | (tester) async { 153 | final emailInput = MockEmailInputModel(); 154 | when(() => emailInput.invalid).thenReturn(true); 155 | when(() => signUpCubit.state) 156 | .thenReturn(SignUpState(emailInput: emailInput)); 157 | await tester.pumpWidget( 158 | MaterialApp( 159 | home: Scaffold( 160 | body: BlocProvider.value( 161 | value: signUpCubit, 162 | child: SignUpForm(), 163 | ), 164 | ), 165 | ), 166 | ); 167 | expect(find.text('invalid email'), findsOneWidget); 168 | }); 169 | 170 | testWidgets('invalid password error text when password is invalid', 171 | (tester) async { 172 | final passwordInput = MockPasswordInputModel(); 173 | when(() => passwordInput.invalid).thenReturn(true); 174 | when(() => signUpCubit.state) 175 | .thenReturn(SignUpState(passwordInput: passwordInput)); 176 | await tester.pumpWidget( 177 | MaterialApp( 178 | home: Scaffold( 179 | body: BlocProvider.value( 180 | value: signUpCubit, 181 | child: SignUpForm(), 182 | ), 183 | ), 184 | ), 185 | ); 186 | expect(find.text('invalid password'), findsOneWidget); 187 | }); 188 | 189 | testWidgets( 190 | 'invalid confirmedPassword error text' 191 | ' when confirmedPassword is invalid', (tester) async { 192 | final confirmedPasswordInput = MockConfirmedPasswordInputModel(); 193 | when(() => confirmedPasswordInput.invalid).thenReturn(true); 194 | when(() => signUpCubit.state).thenReturn( 195 | SignUpState(confirmedPasswordInput: confirmedPasswordInput)); 196 | await tester.pumpWidget( 197 | MaterialApp( 198 | home: Scaffold( 199 | body: BlocProvider.value( 200 | value: signUpCubit, 201 | child: SignUpForm(), 202 | ), 203 | ), 204 | ), 205 | ); 206 | expect(find.text('passwords do not match'), findsOneWidget); 207 | }); 208 | 209 | testWidgets('disabled sign up button when status is not validated', 210 | (tester) async { 211 | when(() => signUpCubit.state).thenReturn( 212 | SignUpState(status: FormzStatus.invalid), 213 | ); 214 | await tester.pumpWidget( 215 | MaterialApp( 216 | home: Scaffold( 217 | body: BlocProvider.value( 218 | value: signUpCubit, 219 | child: SignUpForm(), 220 | ), 221 | ), 222 | ), 223 | ); 224 | final signUpButton = tester.widget( 225 | find.byKey(Key('_SignUpButton')), 226 | ); 227 | expect(signUpButton.enabled, isFalse); 228 | }); 229 | 230 | testWidgets('enabled sign up button when status is validated', 231 | (tester) async { 232 | when(() => signUpCubit.state).thenReturn( 233 | SignUpState(status: FormzStatus.valid), 234 | ); 235 | await tester.pumpWidget( 236 | MaterialApp( 237 | home: Scaffold( 238 | body: BlocProvider.value( 239 | value: signUpCubit, 240 | child: SignUpForm(), 241 | ), 242 | ), 243 | ), 244 | ); 245 | final signUpButton = tester.widget( 246 | find.byKey(Key('_SignUpButton')), 247 | ); 248 | expect(signUpButton.enabled, isTrue); 249 | }); 250 | }); 251 | 252 | group('navigates', () { 253 | testWidgets('back to previous page when submission status is success', 254 | (tester) async { 255 | whenListen( 256 | signUpCubit, 257 | Stream.fromIterable(const [ 258 | SignUpState(status: FormzStatus.submissionInProgress), 259 | SignUpState(status: FormzStatus.submissionSuccess), 260 | ]), 261 | ); 262 | await tester.pumpWidget( 263 | MaterialApp( 264 | home: Scaffold( 265 | body: BlocProvider.value( 266 | value: signUpCubit, 267 | child: SignUpForm(), 268 | ), 269 | ), 270 | ), 271 | ); 272 | expect(find.byType(SignUpForm), findsOneWidget); 273 | await tester.pumpAndSettle(); 274 | expect(find.byType(SignUpForm), findsNothing); 275 | }); 276 | }); 277 | }); 278 | } 279 | -------------------------------------------------------------------------------- /test/screens/splash_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:flutter_idiomatic/import.dart'; 4 | 5 | void main() { 6 | group('SplashScreen', () { 7 | test('has a route', () { 8 | expect(SplashScreen().getRoute(), isA()); 9 | }); 10 | 11 | testWidgets('renders bloc image', (tester) async { 12 | await tester.pumpWidget(MaterialApp(home: SplashScreen())); 13 | expect(find.byKey(Key('_Logo')), findsOneWidget); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /test/widgets/avatar_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:flutter_idiomatic/import.dart'; 5 | 6 | void main() { 7 | const imageUrl = 'https://www.fnordware.com/superpng/pngtest16rgba.png'; 8 | group('Avatar', () { 9 | setUpAll(() => HttpOverrides.global = null); 10 | 11 | testWidgets('renders CircleAvatar', (tester) async { 12 | await tester.pumpWidget(MaterialApp(home: Avatar())); 13 | expect(find.byType(CircleAvatar), findsOneWidget); 14 | }); 15 | 16 | testWidgets('has correct radius', (tester) async { 17 | await tester.pumpWidget(MaterialApp(home: Avatar())); 18 | final avatar = tester.widget(find.byType(CircleAvatar)); 19 | expect(avatar.radius, 48); 20 | }); 21 | 22 | testWidgets('renders backgroundImage if photo is not null', (tester) async { 23 | await tester.pumpWidget(MaterialApp(home: Avatar(photo: imageUrl))); 24 | final avatar = tester.widget(find.byType(CircleAvatar)); 25 | expect(avatar.backgroundImage, isNotNull); 26 | }); 27 | 28 | testWidgets('renders icon if photo is null', (tester) async { 29 | await tester.pumpWidget(MaterialApp(home: Avatar())); 30 | final avatar = tester.widget(find.byType(CircleAvatar)); 31 | expect(avatar.backgroundImage, isNull); 32 | final icon = avatar.child as Icon; 33 | expect(icon.icon, Icons.person_outline); 34 | expect(icon.size, 48); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test_driver/app.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_driver/driver_extension.dart'; 2 | import 'package:flutter_idiomatic/main.dart' as app; 3 | 4 | void main() { 5 | // This line enables the extension 6 | enableFlutterDriverExtension(); 7 | 8 | // Call the `main()` function of your app or call `runApp` with any widget you 9 | // are interested in testing. 10 | app.main(); 11 | } 12 | -------------------------------------------------------------------------------- /test_driver/features/login.feature: -------------------------------------------------------------------------------- 1 | @who member 2 | @what login 3 | @why authentication 4 | Feature: LoginScreen Validates and then Logs in 5 | 6 | Scenario: when email and password are in specified format and login is clicked 7 | Given I have '_EmailInput' and '_PasswordInput' and '_LoginButton' 8 | When I fill the '_EmailInput' field with 'test@example.com' 9 | And I fill the '_PasswordInput' field with 'qwerty12' 10 | And I tap the '_LoginButton' 11 | Then I have 'HomeScreen' 12 | 13 | Scenario: when login state and logout is clicked 14 | Given I have '_LogoutButton' 15 | When I tap the '_LogoutButton' 16 | Then I have 'LoginScreen' 17 | 18 | -------------------------------------------------------------------------------- /test_driver/hook_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:gherkin/gherkin.dart'; 2 | import 'package:flutter_idiomatic/import.dart'; 3 | 4 | class HookExample extends Hook { 5 | /// The priority to assign to this hook. 6 | /// Higher priority gets run first so a priority of 10 is run before a priority of 2 7 | @override 8 | int get priority => 1; 9 | 10 | /// Run before any scenario in a test run have executed 11 | @override 12 | Future onBeforeRun(TestConfiguration config) async { 13 | out('before run hook'); 14 | } 15 | 16 | /// Run after all scenarios in a test run have completed 17 | @override 18 | Future onAfterRun(TestConfiguration config) async { 19 | out('after run hook'); 20 | } 21 | 22 | /// Run before a scenario and it steps are executed 23 | @override 24 | Future onBeforeScenario( 25 | TestConfiguration config, 26 | String scenario, 27 | Iterable tags, 28 | ) async { 29 | out("running hook before scenario '$scenario'"); 30 | } 31 | 32 | /// Run after a scenario has executed 33 | @override 34 | Future onAfterScenario( 35 | TestConfiguration config, 36 | String scenario, 37 | Iterable tags, 38 | ) async { 39 | out("running hook after scenario '$scenario'"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test_driver/main_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutter_gherkin/flutter_gherkin.dart'; 3 | import 'package:gherkin/gherkin.dart'; 4 | import 'package:glob/glob.dart'; 5 | // import 'hook_example.dart'; 6 | import 'step_definitions.dart'; 7 | 8 | Future main() { 9 | final config = FlutterTestConfiguration() 10 | ..features = [Glob(r"test_driver/features/**.feature")] 11 | ..reporters = [ 12 | ProgressReporter(), 13 | TestRunSummaryReporter(), 14 | // JsonReporter(path: './report/report.json') 15 | ] // you can include the "StdoutReporter()" without the message level parameter for verbose log information 16 | ..hooks = [ 17 | // HookExample() 18 | ] // you can include "AttachScreenhotOnFailedStepHook()" to take a screenshot of each step failure and attach it to the world object 19 | ..stepDefinitions = [ 20 | check3(), 21 | tap(), 22 | check(), 23 | ] 24 | // ..customStepParameterDefinitions = [ColourParameter()] 25 | ..restartAppBetweenScenarios = true 26 | // ..buildFlavor = "staging" // uncomment when using build flavor and check android/ios flavor setup see android file android\app\build.gradle 27 | // ..targetDeviceId = "all" // uncomment to run tests on all connected devices or set specific device target id 28 | ..targetDeviceId = "emulator-5554" // $ flutter devices 29 | // ..tagExpression = "@smoke" // uncomment to see an example of running scenarios based on tag expressions 30 | // ..exitAfterTestRun = true // set to false if debugging to exit cleanly 31 | ..targetAppPath = "test_driver/app.dart"; 32 | return GherkinRunner().execute(config); 33 | } 34 | -------------------------------------------------------------------------------- /test_driver/step.dart: -------------------------------------------------------------------------------- 1 | import 'package:gherkin/gherkin.dart' hide step; 2 | import 'package:gherkin/gherkin.dart' as gherkin; 3 | 4 | abstract class Step extends StepDefinition { 5 | Step([StepDefinitionConfiguration? configuration]) : super(configuration); 6 | } 7 | 8 | abstract class StepWithWorld 9 | extends StepDefinition { 10 | StepWithWorld([StepDefinitionConfiguration? configuration]) 11 | : super(configuration); 12 | } 13 | 14 | abstract class Step1WithWorld 15 | extends StepDefinition1 { 16 | Step1WithWorld([StepDefinitionConfiguration? configuration]) 17 | : super(configuration); 18 | } 19 | 20 | abstract class Step1 extends Step1WithWorld { 21 | Step1([StepDefinitionConfiguration? configuration]) : super(configuration); 22 | } 23 | 24 | abstract class Step2WithWorld 25 | extends StepDefinition2 { 26 | Step2WithWorld([StepDefinitionConfiguration? configuration]) 27 | : super(configuration); 28 | } 29 | 30 | abstract class Step2 31 | extends Step2WithWorld { 32 | Step2([StepDefinitionConfiguration? configuration]) : super(configuration); 33 | } 34 | 35 | abstract class Step3WithWorld 36 | extends StepDefinition3 { 37 | Step3WithWorld([StepDefinitionConfiguration? configuration]) 38 | : super(configuration); 39 | } 40 | 41 | abstract class Step3 42 | extends Step3WithWorld { 43 | Step3([StepDefinitionConfiguration? configuration]) : super(configuration); 44 | } 45 | 46 | abstract class Step4WithWorld 48 | extends StepDefinition4 { 49 | Step4WithWorld([StepDefinitionConfiguration? configuration]) 50 | : super(configuration); 51 | } 52 | 53 | abstract class Step4 54 | extends Step4WithWorld { 55 | Step4([StepDefinitionConfiguration? configuration]) : super(configuration); 56 | } 57 | 58 | abstract class Step5WithWorld 60 | extends StepDefinition5 { 62 | Step5WithWorld([StepDefinitionConfiguration? configuration]) 63 | : super(configuration); 64 | } 65 | 66 | abstract class Step5 67 | extends Step5WithWorld { 68 | Step5([StepDefinitionConfiguration? configuration]) : super(configuration); 69 | } 70 | 71 | StepDefinitionGeneric step( 72 | Pattern pattern, 73 | Future Function(StepContext context) onInvoke, { 74 | StepDefinitionConfiguration? configuration, 75 | }) { 76 | return gherkin.step( 77 | pattern, 78 | 0, 79 | onInvoke, 80 | configuration: configuration, 81 | ); 82 | } 83 | 84 | StepDefinitionGeneric step1( 85 | Pattern pattern, 86 | Future Function( 87 | TInput1 input1, 88 | StepContext context, 89 | ) 90 | onInvoke, { 91 | StepDefinitionConfiguration? configuration, 92 | }) { 93 | return gherkin.step( 94 | pattern, 95 | 1, 96 | onInvoke, 97 | configuration: configuration, 98 | ); 99 | } 100 | 101 | StepDefinitionGeneric step2( 102 | Pattern pattern, 103 | Future Function( 104 | TInput1 input1, 105 | TInput2 input2, 106 | StepContext context, 107 | ) 108 | onInvoke, { 109 | StepDefinitionConfiguration? configuration, 110 | }) { 111 | return gherkin.step( 112 | pattern, 113 | 2, 114 | onInvoke, 115 | configuration: configuration, 116 | ); 117 | } 118 | 119 | StepDefinitionGeneric 120 | step3( 121 | Pattern pattern, 122 | Future Function( 123 | TInput1 input1, 124 | TInput2 input2, 125 | TInput3 input3, 126 | StepContext context, 127 | ) 128 | onInvoke, { 129 | StepDefinitionConfiguration? configuration, 130 | }) { 131 | return gherkin.step( 132 | pattern, 133 | 3, 134 | onInvoke, 135 | configuration: configuration, 136 | ); 137 | } 138 | 139 | StepDefinitionGeneric 140 | step4( 141 | Pattern pattern, 142 | Future Function( 143 | TInput1 input1, 144 | TInput2 input2, 145 | TInput3 input3, 146 | TInput4 input4, 147 | StepContext context, 148 | ) 149 | onInvoke, { 150 | StepDefinitionConfiguration? configuration, 151 | }) { 152 | return gherkin.step( 153 | pattern, 154 | 4, 155 | onInvoke, 156 | configuration: configuration, 157 | ); 158 | } 159 | 160 | StepDefinitionGeneric 161 | step5( 162 | Pattern pattern, 163 | Future Function( 164 | TInput1 input1, 165 | TInput1 input2, 166 | TInput1 input3, 167 | TInput1 input4, 168 | TInput1 input5, 169 | StepContext context, 170 | ) 171 | onInvoke, { 172 | StepDefinitionConfiguration? configuration, 173 | }) { 174 | return gherkin.step( 175 | pattern, 176 | 5, 177 | onInvoke, 178 | configuration: configuration, 179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /test_driver/step_definitions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_driver/flutter_driver.dart'; 2 | import 'package:flutter_gherkin/flutter_gherkin.dart'; 3 | import 'package:gherkin/gherkin.dart' hide step; 4 | import 'step.dart'; 5 | 6 | // TODO: перевод для pattern https://github.com/jonsamwell/flutter_gherkin/issues/94 7 | // 'я имею {string} ключ и {string} ключ и {string} ключ', 8 | 9 | StepDefinitionGeneric check3() { 10 | return step3( 11 | 'I have {string} and {string} and {string}', 12 | (String input1, String input2, String input3, 13 | StepContext context) async { 14 | context.expect( 15 | await FlutterDriverUtils.isPresent( 16 | context.world.driver, getFinder(input1)), 17 | true); 18 | context.expect( 19 | await FlutterDriverUtils.isPresent( 20 | context.world.driver, getFinder(input2)), 21 | true); 22 | context.expect( 23 | await FlutterDriverUtils.isPresent( 24 | context.world.driver, getFinder(input3)), 25 | true); 26 | }, 27 | ); 28 | } 29 | 30 | StepDefinitionGeneric tap() { 31 | return step1( 32 | 'I tap the {string}', 33 | (String input, StepContext context) async { 34 | await FlutterDriverUtils.tap(context.world.driver, getFinder(input)); 35 | await FlutterDriverUtils.waitForFlutter(context.world.driver); 36 | }, 37 | ); 38 | } 39 | 40 | // StepDefinitionGeneric checkByKey() { 41 | // return step1( 42 | // 'I have {string} key', 43 | // (String key, StepContext context) async { 44 | // context.expect( 45 | // await FlutterDriverUtils.isPresent( 46 | // context.world.driver, find.byValueKey(key)), 47 | // true); 48 | // }, 49 | // ); 50 | // } 51 | 52 | // StepDefinitionGeneric checkByType() { 53 | // return step1( 54 | // 'I have {string} type', 55 | // (String type, StepContext context) async { 56 | // context.expect( 57 | // await FlutterDriverUtils.isPresent( 58 | // context.world.driver, find.byType(type)), 59 | // true); 60 | // }, 61 | // ); 62 | // } 63 | 64 | StepDefinitionGeneric check() { 65 | return step1( 66 | 'I have {string}', 67 | (String input, StepContext context) async { 68 | context.expect( 69 | await FlutterDriverUtils.isPresent( 70 | context.world.driver, getFinder(input)), 71 | true); 72 | }, 73 | ); 74 | } 75 | 76 | SerializableFinder getFinder(String input) { 77 | final finder = input.startsWith('_') ? find.byValueKey : find.byType; 78 | return finder(input); 79 | } 80 | --------------------------------------------------------------------------------