diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..6108f14a --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.13.9", + "flavors": {} +} \ No newline at end of file diff --git a/README.md b/README.md index 6c2ea7c9..296ed068 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ If you're a VScode user link the new Flutter SDK path in your settings } ``` -

@@ -65,9 +64,14 @@ Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK p

-## Requirements +## App Structure + +### Design + +The app is based on the following [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1). Improvements and new features are welcolme as long as they follow the same visual aspect of the initial design -### App Structure +![List View](screenshots/listview.png) +![Detail View](screenshots/detailview.png) #### Restaurant List Page @@ -96,88 +100,65 @@ Go to `Preferences > Languages & Frameworks > Flutter` and set the Flutter SDK p - User image - Review Text (These are just snippets of the full review, usually like 3-4 lines long) -#### Misc. +### Architechture -- Clear documentation on the structure and architecture of your application. -- Clear and logical commit messages. - - We suggest following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +The project follows a Domain based architecture, a less rigid form of Clean Architecture. On the domain, sets of features may share same widgets, repositories and logic to acomplish the desired tasks. So far, the only domain is the Restaurant itself since it's a small app, therefore dependencies are declared right on top of widget tree, the top of the domain, and injected when needed through the usage of RepositoryProvider and BlocProvider. -## Test Coverage +- Restaurant domain + - data + - Restaurant models + - Yelp Repository + - logic + - FavouriteRestaurantsBloc + - RestaurantReviewsCubit + - RestaurantsBloczz + - presentation + - All Restaurants + - Favourite Restaurants + - Restaurant details -To demonstrate your experience writing different types of tests in Flutter please do the following: +This organization also aims to protect core models from change and make the view dependent on models and logic in a way that make the view reflect the desired business rules of the project. -- Choose ONE portion of your state management and write a unit test. -- Choose ONE widget and write a widget test. +### Presentation -Feel free to add more tests as you see fit but the above is the minimum requirement. +The presentation widgets are splited into two different folders +- common, where widgets used across the Restaurants domain are stored +- views, where widgets representing bigger/complete screen vies are stored -## Design +Common and View widgets both may have widget folders within, where minor widgets are stored to enhance code splitting. This helps improve maintainance, code readability and testing. -- See this [Figma File](https://www.figma.com/file/KsEhQUp66m9yeVkvQ0hSZm/Flutter-Test?node-id=0%3A1) for design information related to the overall look and feel of the application. We do not expect pixel-perfection but would like the application to visually be close to what is specified in the Figma file. +Whenever view or common widgets get bigger, consider spliting into smaller parts within widgets folder. -![List View](screenshots/listview.png) -![Detail View](screenshots/detailview.png) +### Data + +Data folder contains models and repositories that will dictate the domain main data types and how the application interacts with the source of data. -## API +#### Yelp API -The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. We have provided the boilerplate of the API requests and backing data models to save you some time. To successfully make a request to the Yelp GraphQL API, please follow these steps: +The [Yelp GraphQL API](https://www.yelp.com/developers/graphql/guides/intro) is used as the API for this Application. To successfully make a request to the Yelp GraphQL API, please follow these steps: 1. Please go to https://www.yelp.com/signup and sign up for a developer account. 1. Once signed up, navigate to https://www.yelp.com/developers/v3/manage_app. 1. Create a new app by filling out the required information. 1. Once your app is created, scroll down and join the `Developer Beta`. This allows you to use the GraphQL API. -1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](app/lib/yelp_repository.dart) replacing the `` with your key. -1. Run the app and tap the `Fetch Restaurants` button. If you see a log like `Fetched x restaurants` you are all set! - -## Technical Requirements - -### State Management - -Please restrict your usage of state management or dependency injection to the following options: - -1. [provider](https://pub.dev/packages/provider) -2. [Riverpod](https://pub.dev/packages/riverpod) -3. [bloc](https://pub.dev/packages/bloc) -4. [get_it](https://pub.dev/packages/get_it)/[get_it_mixins](https://pub.dev/packages/get_it_mixin) -5. [Mobx](https://pub.dev/packages/mobx) - -We ask this because this challenge values consistency and efficiency over ingenuity. Using commonly used libraries ensures that we can review your code in a timely manner and allows us to provide better feedback. - -## Coding Values - -At **Superformula** we strive to build applications that have - -- Consistent architecture -- Extensible, clean code -- Solid testing -- Good security & performance best practices - -### Clear, consistent architecture - -Approach your submission as if it were a real world app. This includes Use any libraries that you would normally choose. - -_Please note: we're interested in your code & the way you solve the problem, not how well you can use a particular library or feature._ - -### Easy to understand - -Writing boring code that is easy to follow is essential at **Superformula**. - -We're interested in your method and how you approach the problem just as much as we're interested in the end result. +1. Copy your API Key from your app page and paste it on `line 5` [yelp_repository.dart](./lib/data/repositories/yelp_repository.dart) replacing the `` with your key. -### Solid testing approach +#### Yelp Repository -While the purpose of this challenge is not to gauge whether you can achieve 100% test coverage, we do seek to evaluate whether you know how & what to test. +This repository uses Dio and GraphQl notation to fetch data from Yelp GraphQL API. Response models from models folder are used to handle response parsing. -## Q&A +#### Favourite Restaurants -> Where should I send back the result when I'm done? +Due to the simplicity of data, local storage of favourite restaurants is being handled under the hood by HydratedBloc. No repository is necessary to handle this source of data. -Please fork this repo and then send us a pull request to our repo when you think you are done. There is no deadline for this task unless otherwise noted to you directly. +### State management / Logic -> What if I have a question? +The logic folder contains the BLoC files to handle the logic and state of the application. BLoC was chosen to better define the desired states and the transition between them, and also HydratedBloc provided a built-in way to handle local storage of states. There are 3 BLoCs, all of them making use of Yelp Repository: -Just create a new issue in this repo and we will respond and get back to you quickly. +- FavouriteRestaurantsBloc +- RestaurantReviewsCubit +- RestaurantsBloc -## Review +## Misc. -The coding challenge is a take-home test upon which we'll be conducting a thorough code review once complete. The review will consist of meeting some more of our mobile engineers and giving a review of the solution you have designed. Please be prepared to share your screen and run/demo the application to the group. During this process, the engineers will be asking questions. +- This project follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) guidelines \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 24047dce..6bfb6ff0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.0' repositories { google() mavenCentral() @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/assets/yelp_rest_status_response.json b/assets/yelp_rest_status_response.json new file mode 100644 index 00000000..a9407da6 --- /dev/null +++ b/assets/yelp_rest_status_response.json @@ -0,0 +1,12 @@ +{ + "data": { + "business": { + "id": "syhA1ugJpyNLaB0MiP19VA", + "hours": [ + { + "is_open_now": true + } + ] + } + } +} diff --git a/assets/yelp_restaurants_response.json b/assets/yelp_restaurants_response.json new file mode 100644 index 00000000..36c13660 --- /dev/null +++ b/assets/yelp_restaurants_response.json @@ -0,0 +1,1185 @@ +{ + "data": { + "search": { + "total": 6244, + "business": [ + { + "id": "vHz2RLtfUMVRPFmd7VBEHA", + "name": "Gordon Ramsay Hell's Kitchen", + "price": "$$$", + "rating": 4.4, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/q771KjLzI5y638leJsnJnQ/o.jpg" + ], + "reviews": [ + { + "id": "5dBlSgczLAtrmNiVo0EnCg", + "rating": 5, + "user": { + "id": "37h0s9CsU_wNQSYVDoOszQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/ki33wK6EbEk6t7e3CjLUwQ/o.jpg", + "name": "Gregory B." + } + }, + { + "id": "VzJIMZRW-8lwoFJzk0jAXw", + "rating": 5, + "user": { + "id": "i2dS47auJ-9-OW4xZSPxAA", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/M2AsmeEgwVwpjyaE1lFtIA/o.jpg", + "name": "White R." + } + }, + { + "id": "H85bnGMvTx0ACssHvyCyug", + "rating": 5, + "user": { + "id": "3xfzp3cOhKICnLn0D9ZheA", + "image_url": null, + "name": "Molly S." + } + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Seafood", + "alias": "seafood" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3570 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "faPVqws-x-5k2CQKDNtHxw", + "name": "Yardbird", + "price": "$$", + "rating": 4.5, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/xYJaanpF3Dl1OovhmpqAYw/o.jpg" + ], + "reviews": [ + { + "id": "uIeZrx9X1W0XPKqDicXZew", + "rating": 5, + "user": { + "id": "nvcvPpKYpq-nT7wwAexGYw", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/2_pHFKGZ3-SlBq_HTXp8wg/o.jpg", + "name": "Tanner D." + } + }, + { + "id": "V8KFADRFJnsGUvQ3iRtnig", + "rating": 5, + "user": { + "id": "R_PPnsl0gsIvzhq9JHRCXQ", + "image_url": null, + "name": "Misha Z." + } + }, + { + "id": "ZJH4k4Z5eKIZED24mwMNbw", + "rating": 5, + "user": { + "id": "kEjqfvnMVol-m0l-7BCuiA", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/HR7uSBjp0wVFqCLzpcnzPQ/o.jpg", + "name": "Andy C." + } + } + ], + "categories": [ + { + "title": "Southern", + "alias": "southern" + }, + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Cocktail Bars", + "alias": "cocktailbars" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3355 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "syhA1ugJpyNLaB0MiP19VA", + "name": "888 Japanese BBQ", + "price": "$$$", + "rating": 4.8, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/V_zmwCUG1o_vR29xfkb-ng/o.jpg" + ], + "reviews": [ + { + "id": "CfkhNXxjrmAG3Rqth2S8PA", + "rating": 5, + "user": { + "id": "Lh3eh4seBFvfTgybYXL1uw", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/oztzeYdcy2lE6edfoXhapQ/o.jpg", + "name": "Christine X." + } + }, + { + "id": "BVXWtpN-Xn4eIjMe2eceQg", + "rating": 5, + "user": { + "id": "azjuxjks4tfg_HCt1D5zqQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/dD5Mtong507Zkt91BLKLNQ/o.jpg", + "name": "Lexie P." + } + }, + { + "id": "OqxFOKP3qvm3jnnZJaxOUw", + "rating": 5, + "user": { + "id": "-DG-God4RyXPOsaGmPNDcg", + "image_url": null, + "name": "Leilani S." + } + } + ], + "categories": [ + { + "title": "Barbeque", + "alias": "bbq" + }, + { + "title": "Japanese", + "alias": "japanese" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3550 S Decatur Blvd\nLas Vegas, NV 89103" + } + }, + { + "id": "QXV3L_QFGj8r6nWX2kS2hA", + "name": "Nacho Daddy", + "price": "$$", + "rating": 4.4, + "photos": [ + "https://s3-media4.fl.yelpcdn.com/bphoto/pu9doqMplB5x5SEs8ikW6w/o.jpg" + ], + "reviews": [ + { + "id": "IdfbvfvfdqIIvOe6knwz-g", + "rating": 5, + "user": { + "id": "GjP-6ynX_-7Xz5CT_TEeIg", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/sPaIWyv52m1rJ47H7npE3Q/o.jpg", + "name": "Angel T." + } + }, + { + "id": "ZRJNeoKFkVnjWFv7uiUBSg", + "rating": 5, + "user": { + "id": "VtXtUlnnTX78cMQrQJYfJQ", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/gpd9sy4O0ng7HOvfsyn5tA/o.jpg", + "name": "Montse C." + } + }, + { + "id": "31ss_mS7gZ-n87m9WtiO4Q", + "rating": 5, + "user": { + "id": "moPMSTcAmuXV1gGOmrSxCA", + "image_url": null, + "name": "Samuel B." + } + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Mexican", + "alias": "mexican" + }, + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3663 Las Vegas Blvd\nSte 595\nLas Vegas, NV 89109" + } + }, + { + "id": "JPfi__QJAaRzmfh5aOyFEw", + "name": "Shang Artisan Noodle", + "price": "$$", + "rating": 4.6, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/TqV2TDWH-7Wje5B9Oh1EZw/o.jpg" + ], + "reviews": [ + { + "id": "MlbzJT2UhcebcoXtq0kczA", + "rating": 5, + "user": { + "id": "HVwq3FtsOuxQV9DvoPa4RA", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/qptBQ1iGo9kY9wDGF8bFHA/o.jpg", + "name": "Jasmine B." + } + }, + { + "id": "0AG_FlfYBfxlEIQKepSktw", + "rating": 5, + "user": { + "id": "Cco2owhvdzfZ-UAS4tPwKw", + "image_url": null, + "name": "Audrey D." + } + }, + { + "id": "RqBtpBhgvoDmnmRGPPn0Tg", + "rating": 5, + "user": { + "id": "VPYPKuOJ3Q0VVik7w4ee-w", + "image_url": null, + "name": "Jason B." + } + } + ], + "categories": [ + { + "title": "Noodles", + "alias": "noodles" + }, + { + "title": "Chinese", + "alias": "chinese" + }, + { + "title": "Soup", + "alias": "soup" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "4983 W Flamingo Rd\nSte B\nLas Vegas, NV 89103" + } + }, + { + "id": "3kdSl5mo9dWC4clrQjEDGg", + "name": "Egg & I", + "price": "$$", + "rating": 4.5, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/z4rdxoc6xaM4dmdPovPBDg/o.jpg" + ], + "reviews": [ + { + "id": "I85RxcLOx2GZnPPhpRAq_g", + "rating": 5, + "user": { + "id": "NaYTgedWshzhFpbIcUbbQA", + "image_url": null, + "name": "Dolly C." + } + }, + { + "id": "swI8g0sD0RdG__4-81_58w", + "rating": 5, + "user": { + "id": "rgGshDHeoe88kaLVeNFYRA", + "image_url": null, + "name": "Nathaniel H." + } + }, + { + "id": "kJWWyoCHIWIAv869vyu7WQ", + "rating": 5, + "user": { + "id": "rzSlggVUz7zsshFZrcqdwQ", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/la2EgCbJX4oNNe_qMMz3Dw/o.jpg", + "name": "Dana J." + } + } + ], + "categories": [ + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch" + }, + { + "title": "Burgers", + "alias": "burgers" + }, + { + "title": "American", + "alias": "tradamerican" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "4533 W Sahara Ave\nSte 5\nLas Vegas, NV 89102" + } + }, + { + "id": "4JNXUYY8wbaaDmk3BPzlWw", + "name": "Mon Ami Gabi", + "price": "$$$", + "rating": 4.2, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/cZ75DtuiHsOU-4W3vLsFKA/o.jpg" + ], + "reviews": [ + { + "id": "rsFWnc3wXsDeFXa2ATFZiQ", + "rating": 5, + "user": { + "id": "L6TIKUDGuzGRkfvP56-tKA", + "image_url": null, + "name": "Deneen M." + } + }, + { + "id": "cQXxvSa57Tj781NBinYeuQ", + "rating": 5, + "user": { + "id": "nLmugYJe1dSyGhpsKKWDlw", + "image_url": null, + "name": "Maryann H." + } + }, + { + "id": "bPYqNEvJaQxkAeZDmXRCug", + "rating": 5, + "user": { + "id": "4G8bg7wdu_WHezo1c1EYgA", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/jzQtjP49LXCkTH9ANt04KQ/o.jpg", + "name": "Rabindra A." + } + } + ], + "categories": [ + { + "title": "French", + "alias": "french" + }, + { + "title": "Steakhouses", + "alias": "steak" + }, + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3655 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "2iTsRqUsPGRH1li1WVRvKQ", + "name": "Carson Kitchen", + "price": "$$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/LhaPvLHIrsHu8ZMLgV04OQ/o.jpg" + ], + "reviews": [ + { + "id": "59ewmBp3j19Ud3T7Lz4-Ow", + "rating": 4, + "user": { + "id": "7DQSAc84ydnYEP2UMWG0oQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/xV_gdMBDART7WwHp8KPlkA/o.jpg", + "name": "Shannon P." + } + }, + { + "id": "SLncTZbrWzvn4QMiOb1brA", + "rating": 5, + "user": { + "id": "RB_lfittmnIRVL-m-4Q5YQ", + "image_url": null, + "name": "Suanne K." + } + }, + { + "id": "VgtVupmMq2SRy071gm1vRA", + "rating": 5, + "user": { + "id": "c-E9FzfTwAAZkGwyTD6_dw", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/Oifp2BV_QHNZtpGqR0Zg0Q/o.jpg", + "name": "Ruby Q." + } + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Desserts", + "alias": "desserts" + }, + { + "title": "Cocktail Bars", + "alias": "cocktailbars" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "124 S 6th St\nSte 100\nLas Vegas, NV 89101" + } + }, + { + "id": "RESDUcs7fIiihp38-d6_6g", + "name": "Bacchanal Buffet", + "price": "$$$$", + "rating": 3.8, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/oqUpQ_W-8ZrbZKpDh7lYEw/o.jpg" + ], + "reviews": [ + { + "id": "pWMF4T4ISMnLL2uavTcFsA", + "rating": 5, + "user": { + "id": "V3Qh4p-i0q6RyO77qS7llA", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/3vFUAEkl29V7GbcnqgvO9w/o.jpg", + "name": "Livnat A." + } + }, + { + "id": "0zzdPNrVUDImxCKvlP7kHQ", + "rating": 5, + "user": { + "id": "X55cCZntLJ93t5AqLV8Vmg", + "image_url": null, + "name": "Phuc N." + } + }, + { + "id": "SFp74_nmffcW3zIvQpDw4w", + "rating": 5, + "user": { + "id": "QwaMGDUcwaIoWOE6QGriHw", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/6n6DIYSQ9KI-aXe9CBmheg/o.jpg", + "name": "Gilbert M." + } + } + ], + "categories": [ + { + "title": "Buffets", + "alias": "buffets" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3570 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "-1m9o3vGRA8IBPNvNqKLmA", + "name": "Bavette's Steakhouse & Bar", + "price": "$$$$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/pgcnYRHtbw_x_-OG8K4xVg/o.jpg" + ], + "reviews": [ + { + "id": "F8tEzjNcVF778CANn9tkLA", + "rating": 5, + "user": { + "id": "zK4R5IFl5aBqePPEd0fvxw", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/rBt0S0z6NESBswiuaELa8w/o.jpg", + "name": "McKenzie S." + } + }, + { + "id": "I7Ip9foJiDllocxBRGeiSQ", + "rating": 5, + "user": { + "id": "YoaQY0EZmhYCgTLtz5sd2w", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/jgHQJrMrP7kzfbK26guJmg/o.jpg", + "name": "Kim l." + } + }, + { + "id": "np5mM9znAoxH7kad8OzG2w", + "rating": 1, + "user": { + "id": "dK15g-WQtEt_1tz2jIXs2g", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/JXfhN9yAGAb03WYWg4v4OQ/o.jpg", + "name": "Terry G." + } + } + ], + "categories": [ + { + "title": "Steakhouses", + "alias": "steak" + }, + { + "title": "Bars", + "alias": "bars" + }, + { + "title": "New American", + "alias": "newamerican" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3770 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "awI4hHMfa7H0Xf0-ChU5hg", + "name": "The Palace Station Oyster Bar", + "price": "$$", + "rating": 4.4, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/7Rx_j6r85ufd8nOFc7u_fA/o.jpg" + ], + "reviews": [ + { + "id": "i6niYOziXhW2NJA1LroBmg", + "rating": 5, + "user": { + "id": "4hSqVWaqVoHSSemocLN8ig", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/YuGBE_q0VxMS1-omKLMYfA/o.jpg", + "name": "Stephanie R." + } + }, + { + "id": "cff01cXyaIuBtTarRGO9Cw", + "rating": 4, + "user": { + "id": "D4cnxp6k4eemD98E-kphMw", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/oPVnYh0AYTTgU0yswQ3c-w/o.jpg", + "name": "San L." + } + }, + { + "id": "HUgNoBa6JGcnrek39pc1SQ", + "rating": 4, + "user": { + "id": "BUQKlodE0a6H1SwH_-o2UA", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/Ae6xZxrB-ePk7CVlgX2Haw/o.jpg", + "name": "Soo L." + } + } + ], + "categories": [ + { + "title": "Seafood", + "alias": "seafood" + }, + { + "title": "Bars", + "alias": "bars" + }, + { + "title": "Cajun/Creole", + "alias": "cajun" + } + ], + "hours": [ + { + "is_open_now": true + } + ], + "location": { + "formatted_address": "2411 W Sahara Ave\nLas Vegas, NV 89102" + } + }, + { + "id": "_Ad2ZKhUl-krJFpaZ1FI8g", + "name": "Nabe Hotpot", + "price": "$$", + "rating": 4.3, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/tkRdqFIfLe1lTwa6XmUPTA/o.jpg" + ], + "reviews": [ + { + "id": "0wT3ZCZQ11bNQOV95RgVHQ", + "rating": 5, + "user": { + "id": "3D99jvQficOPttTsSJHe8g", + "image_url": null, + "name": "Karter T." + } + }, + { + "id": "pq5ugK0sbm314QyzF_3E8g", + "rating": 5, + "user": { + "id": "zluLxvSPaZnAICWSWkodjg", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/I9yh419iP5joTyay8BzSQg/o.jpg", + "name": "Erian R." + } + }, + { + "id": "cRENEOAoJ9ynXg3w78xAYw", + "rating": 4, + "user": { + "id": "T7ko9V7ceVMJlMFbsihpzw", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/HFGrRFsEVBdUbEAnhPLGXQ/o.jpg", + "name": "Susan H." + } + } + ], + "categories": [ + { + "title": "Hot Pot", + "alias": "hotpot" + }, + { + "title": "Buffets", + "alias": "buffets" + }, + { + "title": "Asian Fusion", + "alias": "asianfusion" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "4545 Spring Mountain Rd\nSte106\nLas Vegas, NV 89103" + } + }, + { + "id": "rdE9gg0WB7Z8kRytIMSapg", + "name": "Lazy Dog Restaurant & Bar", + "price": "$$", + "rating": 4.5, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/_Wz-fNXawmbBinSf9Ev15g/o.jpg" + ], + "reviews": [ + { + "id": "nSRTIxL_xl5m8gykOEq-WQ", + "rating": 5, + "user": { + "id": "zaERxTbPn4TW6f6jynAb_A", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/A7Cov8r811C0tZiVArEn3A/o.jpg", + "name": "Kayleene M." + } + }, + { + "id": "IUYj8Lox9pD1pda-a2rF1w", + "rating": 5, + "user": { + "id": "8ExBbLTrT8Gc1R9r4KhBCg", + "image_url": null, + "name": "Angelica M." + } + }, + { + "id": "2rcy8s1va17xnmQut-qkOw", + "rating": 5, + "user": { + "id": "8h9-semB4gW931Q5B1LJ4w", + "image_url": null, + "name": "mike m." + } + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Comfort Food", + "alias": "comfortfood" + }, + { + "title": "Burgers", + "alias": "burgers" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "6509 S Las Vegas Blvd\nLas Vegas, NV 89119" + } + }, + { + "id": "UidEFF1WpnU4duev4fjPlQ", + "name": "Therapy ", + "price": "$$", + "rating": 4.3, + "photos": [ + "https://s3-media3.fl.yelpcdn.com/bphoto/otaMuPtauoEb6qZzmHlAlQ/o.jpg" + ], + "reviews": [ + { + "id": "rFkhUcAd_toiQF5etzOUFw", + "rating": 5, + "user": { + "id": "TgVjm7u8yWeP7E8E8HLi9w", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/mpciVuTs9rLYXIS2T9Jszg/o.jpg", + "name": "Jose P." + } + }, + { + "id": "1l7Amo9ZDgqOrnR_u59XYg", + "rating": 5, + "user": { + "id": "4UMYSQLSd3_rQe8bhqWreg", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/m9tI9Pw5P7u5S4urIZanDg/o.jpg", + "name": "Kien H." + } + }, + { + "id": "pHPIgHpj7DX6s3XrUNvzkA", + "rating": 2, + "user": { + "id": "zwKLl0R9twRbZyU-i52Wbg", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/u00XjQgIK8xu30gJS_ORzw/o.jpg", + "name": "Ashleigh A." + } + } + ], + "categories": [ + { + "title": "Bars", + "alias": "bars" + }, + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Dance Clubs", + "alias": "danceclubs" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "518 Fremont St\nLas Vegas, NV 89101" + } + }, + { + "id": "gOOfBSBZlffCkQ7dr7cpdw", + "name": "CHICA", + "price": "$$", + "rating": 4.3, + "photos": [ + "https://s3-media2.fl.yelpcdn.com/bphoto/FxmtjuzPDiL7vx5KyceWuQ/o.jpg" + ], + "reviews": [ + { + "id": "KpmUpAaEq--1tyGVIOxSwA", + "rating": 5, + "user": { + "id": "a2pHeokH8l7r3T8nXach0A", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/2LLnjIvr0y9yMen135cJKQ/o.jpg", + "name": "Kam M." + } + }, + { + "id": "f0pNMzyz5SE74XSbYMtvkA", + "rating": 5, + "user": { + "id": "8-6HIm7V3MtKjO1VDNeRAw", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/JMuteh6KHCxq8HLrqtG64w/o.jpg", + "name": "Stella L." + } + }, + { + "id": "tNBknDPqGcdl5v_K4ok7Aw", + "rating": 4, + "user": { + "id": "VThvNKX4R6JTn4T4WFroOw", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/0SOLbfun7i8knyGxnoVObQ/o.jpg", + "name": "Rayshelys B." + } + } + ], + "categories": [ + { + "title": "Latin American", + "alias": "latin" + }, + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch" + }, + { + "title": "Cocktail Bars", + "alias": "cocktailbars" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3355 South Las Vegas Blvd\nSte 106\nLas Vegas, NV 89109" + } + }, + { + "id": "XnJeadLrlj9AZB8qSdIR2Q", + "name": "Joel Robuchon", + "price": "$$$$", + "rating": 4.5, + "photos": [ + "https://s3-media4.fl.yelpcdn.com/bphoto/8282ZD9hrsGH9a-kejFzxw/o.jpg" + ], + "reviews": [ + { + "id": "8vQVgCgiKQ0HjAG_kXeetA", + "rating": 5, + "user": { + "id": "OIa6ptM1qUts5arovQUAFQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/cCb8-5fnQAznL-wz8Cwlew/o.jpg", + "name": "Eric B." + } + }, + { + "id": "EVewlHXfiDa6EW4xf44jog", + "rating": 4, + "user": { + "id": "oEqB6qGiV2K3q8g2A8rfYA", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/50wbYURpTknygA41Gm7bJA/o.jpg", + "name": "Gracie J." + } + }, + { + "id": "GcEYfDEw6KI7Yx6UR8rdMA", + "rating": 5, + "user": { + "id": "Y4iXISephx40OlZGaRjxUw", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/tMExN7NAouyc9NgujptfqQ/o.jpg", + "name": "Tom B." + } + } + ], + "categories": [ + { + "title": "French", + "alias": "french" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3799 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "nUpz0YiBsOK7ff9k3vUJ3A", + "name": "Buddy V's Ristorante", + "price": "$$", + "rating": 4.2, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/gLHjQg0bjGjr_Jus-BXqDA/o.jpg" + ], + "reviews": [ + { + "id": "XvYKeYfYU2mDODBphOlYXA", + "rating": 4, + "user": { + "id": "Vz5KVB8oq1Yd-yBjjUzq7w", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/H-8KWUKW8ujADR3a-LW3wA/o.jpg", + "name": "Teresa L." + } + }, + { + "id": "aigNWful677P85ArYZRkqw", + "rating": 5, + "user": { + "id": "yhZ-fJdtaImuUL-lW6e9-g", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/1sU9tinpG1SuG4e5h9EE3g/o.jpg", + "name": "Nicole T." + } + }, + { + "id": "4tftZUGS6wSsdLmD8oGIDg", + "rating": 4, + "user": { + "id": "oxQsPh1Glu1wUHBTR4Nhug", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/h46Il-KuGYZXhW3agUW5Zw/o.jpg", + "name": "Saturnino C." + } + } + ], + "categories": [ + { + "title": "Italian", + "alias": "italian" + }, + { + "title": "American", + "alias": "tradamerican" + }, + { + "title": "Wine Bars", + "alias": "wine_bars" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3327 S Las Vegas Blvd\nLas Vegas, NV 89109" + } + }, + { + "id": "I6EDDi4-Eq_XlFghcDCUhw", + "name": "Joe's Seafood Prime Steak & Stone Crab", + "price": "$$$", + "rating": 4.4, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/I1GDdV1mWUJM5HTP1PIX6A/o.jpg" + ], + "reviews": [ + { + "id": "6imdz-hd8Mdy6CCHoZ_tww", + "rating": 5, + "user": { + "id": "ZnyskHaWQpv6VyMGdb2xJQ", + "image_url": null, + "name": "Rob M." + } + }, + { + "id": "cRJlV2axHBcfcRK-xUrsHg", + "rating": 5, + "user": { + "id": "NOi1ji1AKPhiE1-R9k3LoA", + "image_url": null, + "name": "Allie M." + } + }, + { + "id": "UspFMU3KmguqGMihIkH5jA", + "rating": 5, + "user": { + "id": "gYgFW1ZF603FuEbqgUEvzw", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/CxRlXjvcv7Icovk1QIFSiQ/o.jpg", + "name": "Elaine L." + } + } + ], + "categories": [ + { + "title": "Seafood", + "alias": "seafood" + }, + { + "title": "Steakhouses", + "alias": "steak" + }, + { + "title": "Wine Bars", + "alias": "wine_bars" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "3500 Las Vegas Blvd S\nLas Vegas, NV 89109" + } + }, + { + "id": "igHYkXZMLAc9UdV5VnR_AA", + "name": "Echo & Rig", + "price": "$$$", + "rating": 4.4, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/Q9swks1BO-w-hVskIHrCVg/o.jpg" + ], + "reviews": [ + { + "id": "KxB6EqbsUAYcXCbogF0j9A", + "rating": 5, + "user": { + "id": "W3opz1HpIXl2krFLJ53lqg", + "image_url": null, + "name": "Ming Z." + } + }, + { + "id": "to14TViy1ksheDKAfHkQ1Q", + "rating": 5, + "user": { + "id": "fe4LgCw7X9TZCocwvr-LTQ", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/JN2tU8aDVk68I3ywHepdkg/o.jpg", + "name": "Shane B." + } + }, + { + "id": "N4t02F-XrnuY_Zf2-tQk1Q", + "rating": 4, + "user": { + "id": "xz81pPXEuon4-7yRg2ptDQ", + "image_url": "https://s3-media3.fl.yelpcdn.com/photo/HWfZOqm3rqN8r0QY-4ORzQ/o.jpg", + "name": "Grace C." + } + } + ], + "categories": [ + { + "title": "Steakhouses", + "alias": "steak" + }, + { + "title": "Butcher", + "alias": "butcher" + }, + { + "title": "Tapas/Small Plates", + "alias": "tapasmallplates" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "440 S Rampart Blvd\nLas Vegas, NV 89145" + } + }, + { + "id": "eJKnymd0BywNPrJw1IuXVw", + "name": "Nacho Daddy Downtown", + "price": "$$", + "rating": 4.2, + "photos": [ + "https://s3-media1.fl.yelpcdn.com/bphoto/wceTIo3pRr_-xUTtIJBVdg/o.jpg" + ], + "reviews": [ + { + "id": "tjpHz85V1TnDzgntWvXOeg", + "rating": 5, + "user": { + "id": "9Qjwa91-0hOtkputU279ig", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/C4lrz8fNoTE8qornQQX_jA/o.jpg", + "name": "Richard W." + } + }, + { + "id": "kaV7U85JFL2vHKMoJjNyAg", + "rating": 5, + "user": { + "id": "aDMLmc5ttBPRZmmO-qI9kQ", + "image_url": null, + "name": "Ian J." + } + }, + { + "id": "87iSEJCmfBm8GWIxPW5J8g", + "rating": 5, + "user": { + "id": "MzSbrpAd59sGy6l8FG3JQg", + "image_url": "https://s3-media2.fl.yelpcdn.com/photo/jlZR8HYRoyhjbp7gE2Ybmg/o.jpg", + "name": "Domonique S." + } + } + ], + "categories": [ + { + "title": "New American", + "alias": "newamerican" + }, + { + "title": "Mexican", + "alias": "mexican" + }, + { + "title": "Breakfast & Brunch", + "alias": "breakfast_brunch" + } + ], + "hours": [ + { + "is_open_now": false + } + ], + "location": { + "formatted_address": "121 N 4th St\nLas Vegas, NV 89101" + } + } + ] + } + } +} diff --git a/assets/yelp_reviews_response.json b/assets/yelp_reviews_response.json new file mode 100644 index 00000000..aa3b7907 --- /dev/null +++ b/assets/yelp_reviews_response.json @@ -0,0 +1,71 @@ +{ + "data": { + "business": { + "review_count": 11265, + "reviews": [ + { + "id": "5dBlSgczLAtrmNiVo0EnCg", + "rating": 5, + "user": { + "id": "37h0s9CsU_wNQSYVDoOszQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/ki33wK6EbEk6t7e3CjLUwQ/o.jpg", + "name": "Gregory B." + }, + "text": "Save the Bees Please ssssssssssss ssssssssss \nOur Food Supply Chain Definitely is in danger" + }, + { + "id": "VzJIMZRW-8lwoFJzk0jAXw", + "rating": 5, + "user": { + "id": "i2dS47auJ-9-OW4xZSPxAA", + "image_url": "https://s3-media1.fl.yelpcdn.com/photo/M2AsmeEgwVwpjyaE1lFtIA/o.jpg", + "name": "White R." + }, + "text": "Amazing!!!! \n\n7 out of 7 !!! I highly recommend.\n\n\n\n\nEvery rabbit stumbles upon a rabbit hole. There are layers to this game. Like dAnte himself said ......." + }, + { + "id": "H85bnGMvTx0ACssHvyCyug", + "rating": 5, + "user": { + "id": "3xfzp3cOhKICnLn0D9ZheA", + "image_url": null, + "name": "Molly S." + }, + "text": "This was the best dining experience I have ever had. I am a professional pastry chef and HUGE Gordon Ramsay fan so Hell's Kitchen was a \"must stop\" and the..." + }, + { + "id": "CfkhNXxjrmAG3Rqth2S8PA", + "rating": 5, + "user": { + "id": "Lh3eh4seBFvfTgybYXL1uw", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/oztzeYdcy2lE6edfoXhapQ/o.jpg", + "name": "Christine X." + }, + "text": "It's very delicious and tender beef . Salmon sushi also really good , yummy yummy yummy ,\nI will recommend my friend to come , love it ." + }, + { + "id": "BVXWtpN-Xn4eIjMe2eceQg", + "rating": 5, + "user": { + "id": "azjuxjks4tfg_HCt1D5zqQ", + "image_url": "https://s3-media4.fl.yelpcdn.com/photo/dD5Mtong507Zkt91BLKLNQ/o.jpg", + "name": "Lexie P." + }, + "text": "I come here a lot of times, they still maintain their quality of food and service. Highly recommend." + }, + { + "id": "OqxFOKP3qvm3jnnZJaxOUw", + "rating": 5, + "user": { + "id": "-DG-God4RyXPOsaGmPNDcg", + "image_url": null, + "name": "Leilani S." + }, + "text": "1st time here and everything was amazing 10/10\n\nTeyah our server was so sweet and had so many recommendations for us to try (great job management for this..." + } + ], + "id": "vHz2RLtfUMVRPFmd7VBEHA", + "name": "Gordon Ramsay Hell's Kitchen" + } + } + } \ No newline at end of file diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee8..ec97fc6f 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee8..c4855bfe 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..fdcc671e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/models/restaurant.dart b/lib/data/models/restaurant.dart similarity index 70% rename from lib/models/restaurant.dart rename to lib/data/models/restaurant.dart index 87c7aab5..c333ff1f 100644 --- a/lib/models/restaurant.dart +++ b/lib/data/models/restaurant.dart @@ -55,11 +55,13 @@ class Review { final String? id; final int? rating; final User? user; + final String? text; const Review({ this.id, this.rating, this.user, + this.text, }); factory Review.fromJson(Map json) => _$ReviewFromJson(json); @@ -135,6 +137,30 @@ class Restaurant { } return false; } + + Restaurant copyWith({ + String? id, + String? name, + String? price, + double? rating, + List? photos, + List? categories, + List? hours, + List? reviews, + Location? location, + }) { + return Restaurant( + id: id ?? this.id, + name: name ?? this.name, + price: price ?? this.price, + rating: rating ?? this.rating, + photos: photos ?? this.photos, + categories: categories ?? this.categories, + hours: hours ?? this.hours, + reviews: reviews ?? this.reviews, + location: location ?? this.location, + ); + } } @JsonSerializable() @@ -153,3 +179,37 @@ class RestaurantQueryResult { Map toJson() => _$RestaurantQueryResultToJson(this); } + +@JsonSerializable() +class ReviewQueryResult { + @JsonKey(name: 'review_count') + final int? total; + @JsonKey(name: 'reviews') + final List? reviewsList; + + const ReviewQueryResult({ + this.total, + this.reviewsList, + }); + + factory ReviewQueryResult.fromJson(Map json) => + _$ReviewQueryResultFromJson(json); + + Map toJson() => _$ReviewQueryResultToJson(this); +} + +@JsonSerializable() +class StatusQueryResult { + final String? id; + final List? hours; + + const StatusQueryResult({ + this.id, + this.hours, + }); + + factory StatusQueryResult.fromJson(Map json) => + _$StatusQueryResultFromJson(json); + + Map toJson() => _$StatusQueryResultToJson(this); +} diff --git a/lib/models/restaurant.g.dart b/lib/data/models/restaurant.g.dart similarity index 78% rename from lib/models/restaurant.g.dart rename to lib/data/models/restaurant.g.dart index 3ed33f9a..4fa10886 100644 --- a/lib/models/restaurant.g.dart +++ b/lib/data/models/restaurant.g.dart @@ -42,12 +42,14 @@ Review _$ReviewFromJson(Map json) => Review( user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + text: json['text'] as String?, ); Map _$ReviewToJson(Review instance) => { 'id': instance.id, 'rating': instance.rating, 'user': instance.user, + 'text': instance.text, }; Location _$LocationFromJson(Map json) => Location( @@ -107,3 +109,31 @@ Map _$RestaurantQueryResultToJson( 'total': instance.total, 'business': instance.restaurants, }; + +ReviewQueryResult _$ReviewQueryResultFromJson(Map json) => + ReviewQueryResult( + total: json['review_count'] as int?, + reviewsList: (json['reviews'] as List?) + ?.map((e) => Review.fromJson(e as Map)) + .toList(), + ); + +Map _$ReviewQueryResultToJson(ReviewQueryResult instance) => + { + 'review_count': instance.total, + 'reviews': instance.reviewsList, + }; + +StatusQueryResult _$StatusQueryResultFromJson(Map json) => + StatusQueryResult( + id: json['id'] as String?, + hours: (json['hours'] as List?) + ?.map((e) => Hours.fromJson(e as Map)) + .toList(), + ); + +Map _$StatusQueryResultToJson(StatusQueryResult instance) => + { + 'id': instance.id, + 'hours': instance.hours, + }; diff --git a/lib/data/repositories/yelp_repository.dart b/lib/data/repositories/yelp_repository.dart new file mode 100644 index 00000000..4c8ead59 --- /dev/null +++ b/lib/data/repositories/yelp_repository.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; + +const _apiKey = ''; + +class MockResponse { + final Map data; + MockResponse(this.data); +} + +class YelpRepository { + late Dio dio; + + YelpRepository({ + @visibleForTesting Dio? dio, + }) : dio = dio ?? + Dio( + BaseOptions( + baseUrl: 'https://api.yelp.com', + headers: { + 'Authorization': 'Bearer $_apiKey', + 'Content-Type': 'application/graphql', + }, + ), + ); + + Future getRestaurants({int offset = 0}) async { + try { + final response; + + if (kReleaseMode) { + response = await dio.post>( + '/v3/graphql', + data: _getQuery(offset), + ); + } else { + response = await mockRestaurantsResponse(); + } + + return RestaurantQueryResult.fromJson(response.data!['data']['search']); + } on DioException catch (error) { + debugPrint(error.message); + rethrow; + } + } + + Future mockRestaurantsResponse() async { + await Future.delayed(const Duration(seconds: 2)); + final jsonString = + await rootBundle.loadString('assets/yelp_restaurants_response.json'); + return MockResponse(jsonDecode(jsonString)); + } + + Future getRestaurantReviews({ + required String restaurantId, + int offset = 0, + }) async { + try { + final response; + + if (kReleaseMode) { + response = await dio.post>( + '/v3/graphql', + data: _getReviewsQuery(restaurantId, offset), + ); + } else { + response = await mockRestaurantReviewsResponse(); + } + + return ReviewQueryResult.fromJson(response.data!['data']['business']); + } on DioException catch (error) { + debugPrint(error.message); + rethrow; + } + } + + Future mockRestaurantReviewsResponse() async { + await Future.delayed(const Duration(seconds: 2)); + final jsonString = + await rootBundle.loadString('assets/yelp_reviews_response.json'); + return MockResponse(jsonDecode(jsonString)); + } + + Future getRestaurantStatus(String restaurantId) async { + try { + final response; + + if (kReleaseMode) { + response = await dio.post>( + '/v3/graphql', + data: _getRestaurantStatusQuery(restaurantId), + ); + } else { + response = await mockRestaurantStatusResponse(restaurantId); + } + + return StatusQueryResult.fromJson(response.data!['data']['business']); + } on DioException catch (error) { + debugPrint(error.message); + rethrow; + } + } + + Future mockRestaurantStatusResponse(String restaurantId) async { + await Future.delayed(const Duration(seconds: 2)); + final jsonString = + await rootBundle.loadString('assets/yelp_rest_status_response.json'); + + var mock = MockResponse(jsonDecode(jsonString)); + mock.data['data']['business']['id'] = restaurantId; + return mock; + } + + String _getQuery(int offset) { + return ''' +query getRestaurants { + search(location: "Las Vegas", limit: 20, offset: $offset) { + total + business { + id + name + price + rating + photos + reviews { + id + rating + user { + id + image_url + name + } + } + categories { + title + alias + } + hours { + is_open_now + } + location { + formatted_address + } + } + } + } +'''; + } + + String _getReviewsQuery(String restaurantId, int offset) { + return ''' +query getRestaurantReviews { + business(id: "$restaurantId") { + review_count + reviews(limit: 20, offset: $offset) { + id + rating + user { + id + image_url + name + } + text + } + id + name + } +} +'''; + } + + String _getRestaurantStatusQuery(String restaurantId) { + return ''' +query getRestaurantStatus { + business(id: "$restaurantId") { + id + hours { + is_open_now + } + } +} +'''; + } +} diff --git a/lib/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart new file mode 100644 index 00000000..0372f7aa --- /dev/null +++ b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:meta/meta.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; + +part 'favourite_restaurants_event.dart'; +part 'favourite_restaurants_state.dart'; + +class FavouriteRestaurantsBloc + extends HydratedBloc { + final YelpRepository yelpRepository; + List favouriteRestaurants = []; + + FavouriteRestaurantsBloc({required this.yelpRepository}) + : super(FavouriteRestaurantsInitial()) { + on((event, emit) async { + favouriteRestaurants.add(event.restaurant); + emit(FavouriteRestaurantsData(favouriteRestaurants)); + }); + + on((event, emit) { + final int readIndex = favouriteRestaurants.indexWhere( + (item) => item.id == event.restaurantId, + ); + favouriteRestaurants.removeAt(readIndex); + emit(FavouriteRestaurantsData(favouriteRestaurants)); + }); + + on((event, emit) async { + try { + final futures = favouriteRestaurants + .map((fav) => fav.id) + .map( + (id) => id != null + ? yelpRepository.getRestaurantStatus( + id, + ) + : Future.value(null), + ) + .toList(); + + final responseData = await Future.wait(futures); + responseData + .where((r) => r != null && r.id != null && r.hours != null) + .forEach( + (fav) { + final index = favouriteRestaurants.indexWhere( + (item) => fav!.id == item.id, + ); + favouriteRestaurants[index] = favouriteRestaurants[index].copyWith( + hours: fav!.hours, + ); + }, + ); + } on DioException catch (error) { + emit(FavouriteRestaurantsError(error.message ?? error.toString())); + } catch (error) { + emit(FavouriteRestaurantsError(error.toString())); + } finally { + emit(FavouriteRestaurantsData(favouriteRestaurants)); + } + }); + } + + @override + FavouriteRestaurantsState? fromJson(Map json) { + try { + final String state = json['FavouriteRestaurantsState'] as String; + switch (state) { + case 'data': + { + final List data = jsonDecode(json["restaurantsList"]); + final List parsedList = data + .map( + (dynamic item) => + Restaurant.fromJson(item as Map), + ) + .toList(); + favouriteRestaurants = parsedList; + return FavouriteRestaurantsData(parsedList); + } + default: + return FavouriteRestaurantsInitial(); + } + } catch (_) { + return FavouriteRestaurantsInitial(); + } + } + + @override + Map? toJson(FavouriteRestaurantsState state) { + try { + if (state is FavouriteRestaurantsData) { + final jsonList = state.restaurantsList.map((e) => e.toJson()).toList(); + return { + "FavouriteRestaurantsState": "data", + 'restaurantsList': jsonEncode(jsonList), + }; + } else { + return null; + } + } catch (_) { + return null; + } + } +} diff --git a/lib/logic/favourite_restaurants_bloc/favourite_restaurants_event.dart b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_event.dart new file mode 100644 index 00000000..fbf64e33 --- /dev/null +++ b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_event.dart @@ -0,0 +1,18 @@ +part of 'favourite_restaurants_bloc.dart'; + +@immutable +abstract class FavouriteRestaurantsEvent {} + +class FavRestaurant extends FavouriteRestaurantsEvent { + FavRestaurant({required this.restaurant}); + final Restaurant restaurant; +} + +class UnfavRestaurant extends FavouriteRestaurantsEvent { + UnfavRestaurant({required this.restaurantId}); + final String restaurantId; +} + +class LoadRestaurantsStatus extends FavouriteRestaurantsEvent { + LoadRestaurantsStatus(); +} diff --git a/lib/logic/favourite_restaurants_bloc/favourite_restaurants_state.dart b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_state.dart new file mode 100644 index 00000000..1ed5ac92 --- /dev/null +++ b/lib/logic/favourite_restaurants_bloc/favourite_restaurants_state.dart @@ -0,0 +1,16 @@ +part of 'favourite_restaurants_bloc.dart'; + +@immutable +abstract class FavouriteRestaurantsState {} + +class FavouriteRestaurantsInitial extends FavouriteRestaurantsState {} + +class FavouriteRestaurantsData extends FavouriteRestaurantsState { + FavouriteRestaurantsData(this.restaurantsList); + final List restaurantsList; +} + +class FavouriteRestaurantsError extends FavouriteRestaurantsState { + FavouriteRestaurantsError(this.errorMessage); + final String errorMessage; +} diff --git a/lib/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.dart b/lib/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.dart new file mode 100644 index 00000000..73bd7d8a --- /dev/null +++ b/lib/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.dart @@ -0,0 +1,40 @@ +import 'package:bloc/bloc.dart'; +import 'package:dio/dio.dart'; +import 'package:meta/meta.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; + +part 'restaurant_reviews_state.dart'; + +class RestaurantReviewsCubit extends Cubit { + final YelpRepository yelpRepository; + + RestaurantReviewsCubit({required this.yelpRepository}) + : super(RestaurantReviewsIdle()); + + Future> loadReviews({ + required String restaurantId, + }) async { + emit(RestaurantReviewsLoading()); + + try { + final response = await yelpRepository.getRestaurantReviews( + restaurantId: restaurantId, + ); + emit( + RestaurantReviewsData( + response?.reviewsList ?? [], + response?.total ?? 0, + ), + ); + return response?.reviewsList ?? []; + } on DioException catch (error) { + emit(RestaurantReviewsError(error.message ?? error.toString())); + } catch (error) { + emit(RestaurantReviewsError(error.toString())); + } finally { + emit(RestaurantReviewsIdle()); + } + return []; + } +} diff --git a/lib/logic/restaurant_reviews_cubit/restaurant_reviews_state.dart b/lib/logic/restaurant_reviews_cubit/restaurant_reviews_state.dart new file mode 100644 index 00000000..68996d7b --- /dev/null +++ b/lib/logic/restaurant_reviews_cubit/restaurant_reviews_state.dart @@ -0,0 +1,19 @@ +part of 'restaurant_reviews_cubit.dart'; + +@immutable +abstract class RestaurantReviewsState {} + +class RestaurantReviewsIdle extends RestaurantReviewsState {} + +class RestaurantReviewsLoading extends RestaurantReviewsState {} + +class RestaurantReviewsData extends RestaurantReviewsState { + RestaurantReviewsData(this.reviewsList, this.reviewCount); + final List reviewsList; + final int reviewCount; +} + +class RestaurantReviewsError extends RestaurantReviewsState { + RestaurantReviewsError(this.errorMessage); + final String errorMessage; +} diff --git a/lib/logic/restaurants_bloc/restaurants_bloc.dart b/lib/logic/restaurants_bloc/restaurants_bloc.dart new file mode 100644 index 00000000..643cff25 --- /dev/null +++ b/lib/logic/restaurants_bloc/restaurants_bloc.dart @@ -0,0 +1,34 @@ +import 'package:bloc/bloc.dart'; +import 'package:dio/dio.dart'; +import 'package:meta/meta.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; + +part 'restaurants_event.dart'; +part 'restaurants_state.dart'; + +class RestaurantsBloc extends Bloc { + final YelpRepository yelpRepository; + + RestaurantsBloc({required this.yelpRepository}) + : super(RestaurantsInitial()) { + List restaurantsList = []; + + on((event, emit) async { + try { + emit(RestaurantsLoading()); + final responseData = await yelpRepository.getRestaurants( + offset: event.offset ?? 0, + ); + final fetchedRestaurants = responseData?.restaurants ?? []; + restaurantsList.addAll(fetchedRestaurants); + } on DioException catch (error) { + emit(RestaurantsError(error.message ?? error.toString())); + } catch (error) { + emit(RestaurantsError(error.toString())); + } finally { + emit(RestaurantsData(restaurantsList)); + } + }); + } +} diff --git a/lib/logic/restaurants_bloc/restaurants_event.dart b/lib/logic/restaurants_bloc/restaurants_event.dart new file mode 100644 index 00000000..008c5077 --- /dev/null +++ b/lib/logic/restaurants_bloc/restaurants_event.dart @@ -0,0 +1,9 @@ +part of 'restaurants_bloc.dart'; + +@immutable +abstract class RestaurantsEvent {} + +class LoadRestaurants extends RestaurantsEvent { + LoadRestaurants({this.offset}); + final int? offset; +} diff --git a/lib/logic/restaurants_bloc/restaurants_state.dart b/lib/logic/restaurants_bloc/restaurants_state.dart new file mode 100644 index 00000000..463890d8 --- /dev/null +++ b/lib/logic/restaurants_bloc/restaurants_state.dart @@ -0,0 +1,18 @@ +part of 'restaurants_bloc.dart'; + +@immutable +abstract class RestaurantsState {} + +class RestaurantsInitial extends RestaurantsState {} + +class RestaurantsLoading extends RestaurantsState {} + +class RestaurantsError extends RestaurantsState { + RestaurantsError(this.errorMessage); + final String errorMessage; +} + +class RestaurantsData extends RestaurantsState { + RestaurantsData(this.restaurantsList); + final List restaurantsList; +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..a0f01680 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,57 +1,21 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:restaurantour/restaurantour.dart'; -void main() { - runApp(const Restaurantour()); -} - -class Restaurantour extends StatelessWidget { - // This widget is the root of your application. - const Restaurantour({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'RestauranTour', - theme: ThemeData( - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: const HomePage(), - ); - } -} +void main() async { + WidgetsFlutterBinding.ensureInitialized(); -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); + final storage = await HydratedStorage.build( + storageDirectory: kIsWeb + ? HydratedStorage.webStorageDirectory + : await getApplicationDocumentsDirectory(), + ); - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Restaurantour'), - ElevatedButton( - child: const Text('Fetch Restaurants'), - onPressed: () async { - final yelpRepo = YelpRepository(); + HydratedBloc.storage = storage; - try { - final result = await yelpRepo.getRestaurants(); - if (result != null) { - print('Fetched ${result.restaurants!.length} restaurants'); - } else { - print('No restaurants fetched'); - } - } catch (e) { - print('Failed to fetch restaurants: $e'); - } - }, - ), - ], - ), - ), - ); - } + runApp( + const RestaurantTour(), + ); } diff --git a/lib/presentation/common/column_loading_placeholder/column_loading_placeholder.dart b/lib/presentation/common/column_loading_placeholder/column_loading_placeholder.dart new file mode 100644 index 00000000..fd1a07ba --- /dev/null +++ b/lib/presentation/common/column_loading_placeholder/column_loading_placeholder.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class ColumnLoadingPlaceholder extends StatelessWidget { + const ColumnLoadingPlaceholder({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + semanticsLabel: 'Loading', + color: Colors.black, + ), + ], + ); + } +} diff --git a/lib/presentation/common/network_hero/network_image_hero.dart b/lib/presentation/common/network_hero/network_image_hero.dart new file mode 100644 index 00000000..bec515e9 --- /dev/null +++ b/lib/presentation/common/network_hero/network_image_hero.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class NetworkImageHero extends StatelessWidget { + final String? imageLink; + final String heroId; + const NetworkImageHero({ + Key? key, + required this.imageLink, + required this.heroId, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Hero( + tag: heroId, + child: imageLink != null + ? Image.network( + imageLink!, + fit: BoxFit.cover, + ) + : Container( + color: Colors.black26, + child: const Icon( + Icons.restaurant_outlined, + size: 32.0, + color: Colors.white, + ), + ), + ); + } +} diff --git a/lib/presentation/common/restaurant_card/restaurant_card.dart b/lib/presentation/common/restaurant_card/restaurant_card.dart new file mode 100644 index 00000000..c27db91b --- /dev/null +++ b/lib/presentation/common/restaurant_card/restaurant_card.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/categories.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/image.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/price.dart'; +import 'package:restaurantour/presentation/common/restaurant_rating/rating.dart'; +import 'package:restaurantour/presentation/common/restaurant_status/restaurant_status.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/title.dart'; + +class RestaurantCard extends StatelessWidget { + final Restaurant restaurant; + final VoidCallback onTap; + + const RestaurantCard( + this.restaurant, { + Key? key, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Card( + elevation: 2, + borderOnForeground: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + RestaurantCardImage( + restaurant.heroImage, + heroId: restaurant.id ?? restaurant.name ?? "", + ), + const SizedBox(width: 12.0), + Expanded( + child: SizedBox( + height: 88.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RestaurantCardName(restaurant.name), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + RestaurantCardPrice(restaurant.price), + const SizedBox(width: 4.0), + RestaurantCardCategories(restaurant.categories), + ], + ), + const SizedBox(height: 4.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + RestaurantRating(restaurant.rating?.round()), + RestaurantStatus(restaurant.isOpen), + ], + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/common/restaurant_card/widgets/categories.dart b/lib/presentation/common/restaurant_card/widgets/categories.dart new file mode 100644 index 00000000..8be1de90 --- /dev/null +++ b/lib/presentation/common/restaurant_card/widgets/categories.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; + +class RestaurantCardCategories extends StatelessWidget { + final List? categories; + const RestaurantCardCategories(this.categories, {Key? key}) : super(key: key); + + String getCategoriesTextList() { + if (categories == null) return ""; + + String listString = categories! + .take(2) + .map((category) => category.title ?? category.alias ?? "") + .join(', '); + + if (categories!.length > 2) listString = '$listString and others.'; + + return listString; + } + + @override + Widget build(BuildContext context) { + if (categories == null) { + return const SizedBox(); + } else { + return Text( + getCategoriesTextList(), + style: GoogleFonts.openSans(fontSize: 12.0), + ); + } + } +} diff --git a/lib/presentation/common/restaurant_card/widgets/image.dart b/lib/presentation/common/restaurant_card/widgets/image.dart new file mode 100644 index 00000000..75391806 --- /dev/null +++ b/lib/presentation/common/restaurant_card/widgets/image.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/presentation/common/network_hero/network_image_hero.dart'; + +class RestaurantCardImage extends StatelessWidget { + final String imageLink; + final String heroId; + const RestaurantCardImage( + this.imageLink, { + Key? key, + required this.heroId, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 88.0, + height: 88.0, + child: ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: NetworkImageHero( + imageLink: imageLink, + heroId: heroId, + ), + ), + ); + } +} diff --git a/lib/presentation/common/restaurant_card/widgets/price.dart b/lib/presentation/common/restaurant_card/widgets/price.dart new file mode 100644 index 00000000..419213b1 --- /dev/null +++ b/lib/presentation/common/restaurant_card/widgets/price.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class RestaurantCardPrice extends StatelessWidget { + final String? price; + const RestaurantCardPrice(this.price, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return price == null + ? const SizedBox() + : Text( + price!, + style: GoogleFonts.openSans(fontSize: 12.0), + ); + } +} diff --git a/lib/presentation/common/restaurant_card/widgets/title.dart b/lib/presentation/common/restaurant_card/widgets/title.dart new file mode 100644 index 00000000..84fb1368 --- /dev/null +++ b/lib/presentation/common/restaurant_card/widgets/title.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class RestaurantCardName extends StatelessWidget { + final String? title; + const RestaurantCardName(this.title, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Text( + title ?? "--------", + softWrap: true, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.lora( + fontSize: 24.0, + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/lib/presentation/common/restaurant_rating/rating.dart b/lib/presentation/common/restaurant_rating/rating.dart new file mode 100644 index 00000000..8795be64 --- /dev/null +++ b/lib/presentation/common/restaurant_rating/rating.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class RestaurantRating extends StatelessWidget { + final int? rating; + final double size; + const RestaurantRating(this.rating, {Key? key, this.size = 14}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final arr = List.generate(rating != null ? rating! : 0, (i) => i + 1); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...arr + .map( + (_) => Icon( + Icons.star, + color: Colors.amber[800], + size: size, + semanticLabel: 'Rating', + ), + ) + .toList(), + ], + ); + } +} diff --git a/lib/presentation/common/restaurant_status/restaurant_status.dart b/lib/presentation/common/restaurant_status/restaurant_status.dart new file mode 100644 index 00000000..f2c10283 --- /dev/null +++ b/lib/presentation/common/restaurant_status/restaurant_status.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class RestaurantStatus extends StatelessWidget { + final bool isOpenNow; + const RestaurantStatus(this.isOpenNow, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + isOpenNow ? 'Open Now' : 'Closed', + style: GoogleFonts.openSans( + fontSize: 14.0, + fontStyle: FontStyle.italic, + height: 0.8, + ), + ), + const SizedBox(width: 4.0), + Icon( + Icons.circle, + color: isOpenNow ? Colors.green : Colors.red, + size: 8.0, + semanticLabel: 'Rating', + ), + ], + ); + } +} diff --git a/lib/presentation/common/restaurants_list_view/restaurants_list_view.dart b/lib/presentation/common/restaurants_list_view/restaurants_list_view.dart new file mode 100644 index 00000000..40e943f4 --- /dev/null +++ b/lib/presentation/common/restaurants_list_view/restaurants_list_view.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; +import 'package:restaurantour/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/restaurant_card.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/restaurant_details_page.dart'; + +class RestaurantsListView extends StatelessWidget { + final List list; + const RestaurantsListView(this.list, {Key? key}) : super(key: key); + + void onTapRestaurant(BuildContext context, Restaurant restaurant) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => RepositoryProvider.value( + value: RepositoryProvider.of(context), + child: BlocProvider.value( + value: BlocProvider.of(context), + child: RestaurantDetailsPage(restaurant), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(8), + itemCount: list.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (BuildContext context, int index) { + return RestaurantCard( + list[index], + onTap: () => onTapRestaurant(context, list[index]), + ); + }, + ); + } +} diff --git a/lib/presentation/views/all_restaurants/all_restaurants_page.dart b/lib/presentation/views/all_restaurants/all_restaurants_page.dart new file mode 100644 index 00000000..0757a592 --- /dev/null +++ b/lib/presentation/views/all_restaurants/all_restaurants_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/logic/restaurants_bloc/restaurants_bloc.dart'; +import 'package:restaurantour/presentation/common/column_loading_placeholder/column_loading_placeholder.dart'; + import 'package:restaurantour/presentation/common/restaurants_list_view/restaurants_list_view.dart'; + +class AllRestaurantsPage extends StatelessWidget { + const AllRestaurantsPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is RestaurantsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + elevation: 4, + margin: const EdgeInsets.only( + bottom: 0.0, + right: 24, + left: 24, + ), + content: Text( + state.errorMessage, + style: const TextStyle(color: Colors.white), + ), + backgroundColor: Colors.red, + ), + ); + } + }, + builder: (context, state) { + if (state is RestaurantsLoading) { + return const ColumnLoadingPlaceholder(); + } else if (state is RestaurantsData) { + return RestaurantsListView(state.restaurantsList); + } else { + return const SizedBox(); + } + }, + ); + } +} diff --git a/lib/presentation/views/favourite_restaurants/favourite_restaurants_page.dart b/lib/presentation/views/favourite_restaurants/favourite_restaurants_page.dart new file mode 100644 index 00000000..5eab3e84 --- /dev/null +++ b/lib/presentation/views/favourite_restaurants/favourite_restaurants_page.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart'; +import 'package:restaurantour/presentation/common/restaurants_list_view/restaurants_list_view.dart'; +import 'package:restaurantour/presentation/views/favourite_restaurants/widgets/list_placeholder.dart'; + +class FavouriteRestaurantsPage extends StatefulWidget { + const FavouriteRestaurantsPage({Key? key}) : super(key: key); + + @override + State createState() => + _FavouriteRestaurantsPageState(); +} + +class _FavouriteRestaurantsPageState extends State { + @override + void initState() { + BlocProvider.of(context) + .add(LoadRestaurantsStatus()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (context, state) { + if (state is FavouriteRestaurantsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + elevation: 4, + margin: const EdgeInsets.only( + bottom: 0.0, + right: 24, + left: 24, + ), + content: Text( + state.errorMessage, + style: const TextStyle(color: Colors.white), + ), + backgroundColor: Colors.red, + ), + ); + } + }, + builder: (context, state) { + if (state is FavouriteRestaurantsData && + state.restaurantsList.isNotEmpty) { + return RestaurantsListView(state.restaurantsList); + } else { + return const FavListPlaceholder(); + } + }, + ); + } +} diff --git a/lib/presentation/views/favourite_restaurants/widgets/list_placeholder.dart b/lib/presentation/views/favourite_restaurants/widgets/list_placeholder.dart new file mode 100644 index 00000000..d081b3a0 --- /dev/null +++ b/lib/presentation/views/favourite_restaurants/widgets/list_placeholder.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class FavListPlaceholder extends StatelessWidget { + const FavListPlaceholder({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.favorite_border, + color: Colors.black54, + size: 54.0, + ), + const SizedBox(height: 20.0), + Text( + "You don't have any favorite restaurants yet", + textAlign: TextAlign.center, + style: GoogleFonts.openSans( + fontSize: 18.0, + color: Colors.black54, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8.0), + Text( + "Mark a restaurant as a favorite \nso it appears on the list", + textAlign: TextAlign.center, + style: GoogleFonts.openSans( + fontSize: 14.0, + color: Colors.black54, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/views/home/home_page.dart b/lib/presentation/views/home/home_page.dart new file mode 100644 index 00000000..c994a99d --- /dev/null +++ b/lib/presentation/views/home/home_page.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/presentation/views/favourite_restaurants/favourite_restaurants_page.dart'; +import 'package:restaurantour/presentation/views/all_restaurants/all_restaurants_page.dart'; + +class HomePage extends StatelessWidget { + const HomePage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + centerTitle: true, + backgroundColor: Colors.white, + title: Text( + 'RestaurantTour', + style: GoogleFonts.lora( + fontSize: 24.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + bottom: const TabBar( + isScrollable: true, + labelColor: Colors.black, + unselectedLabelColor: Colors.black54, + indicatorColor: Colors.black, + tabs: [ + Tab(text: "All Restaurants"), + Tab(text: "My Favourites"), + ], + ), + ), + body: const TabBarView( + children: [ + AllRestaurantsPage(), + FavouriteRestaurantsPage(), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/views/restaurant_details/restaurant_details_page.dart b/lib/presentation/views/restaurant_details/restaurant_details_page.dart new file mode 100644 index 00000000..ce1838cd --- /dev/null +++ b/lib/presentation/views/restaurant_details/restaurant_details_page.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; +import 'package:restaurantour/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart'; +import 'package:restaurantour/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.dart'; +import 'package:restaurantour/presentation/common/column_loading_placeholder/column_loading_placeholder.dart'; +import 'package:restaurantour/presentation/common/network_hero/network_image_hero.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/restaurant_data.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/review_list.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/spaced_divider.dart'; + +class RestaurantDetailsPage extends StatelessWidget { + final Restaurant restaurant; + const RestaurantDetailsPage(this.restaurant, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => RestaurantReviewsCubit( + yelpRepository: RepositoryProvider.of(context), + )..loadReviews(restaurantId: restaurant.id ?? ""), + child: RestaurantDetailsPageView(restaurant), + ); + } +} + +class RestaurantDetailsPageView extends StatefulWidget { + final Restaurant restaurant; + const RestaurantDetailsPageView(this.restaurant, {Key? key}) + : super(key: key); + + @override + State createState() => + _RestaurantDetailsPageViewState(); +} + +class _RestaurantDetailsPageViewState extends State { + List reviewList = []; + int reviewCount = 0; + late bool isFavourite; + + @override + void initState() { + final favRestBlocState = + BlocProvider.of(context).state; + if (favRestBlocState is! FavouriteRestaurantsData) { + isFavourite = false; + } else { + final favIndex = favRestBlocState.restaurantsList.indexWhere( + (element) => element.id == widget.restaurant.id, + ); + + isFavourite = favIndex != -1; + } + + super.initState(); + } + + void onToggleFavourite() { + if (isFavourite) { + BlocProvider.of(context).add( + UnfavRestaurant( + restaurantId: widget.restaurant.id ?? "", + ), + ); + } else { + BlocProvider.of(context).add( + FavRestaurant( + restaurant: widget.restaurant, + ), + ); + } + setState(() { + isFavourite = !isFavourite; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + title: Text( + widget.restaurant.name ?? "", + overflow: TextOverflow.ellipsis, + style: GoogleFonts.lora( + fontSize: 24.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + leading: const BackButton( + color: Colors.black, + ), + actions: [ + IconButton( + onPressed: onToggleFavourite, + icon: Icon( + isFavourite ? Icons.favorite : Icons.favorite_outline, + color: Colors.black, + ), + ), + ], + ), + body: BlocConsumer( + listener: (context, state) { + if (state is RestaurantReviewsData) { + setState(() { + reviewList = state.reviewsList; + reviewCount = state.reviewCount; + }); + } else if (state is RestaurantReviewsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + elevation: 4, + margin: const EdgeInsets.only( + bottom: 0.0, + right: 24, + left: 24, + ), + content: Text( + state.errorMessage, + style: const TextStyle(color: Colors.white), + ), + backgroundColor: Colors.red, + ), + ); + } + }, + builder: (context, state) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 361.0, + width: double.infinity, + child: NetworkImageHero( + imageLink: widget.restaurant.heroImage, + heroId: + widget.restaurant.id ?? widget.restaurant.name ?? "", + ), + ), + const SizedBox(height: 24.0), + RestaurantDetails(widget.restaurant), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: SpacedDivider(), + ), + if (state is RestaurantReviewsLoading) + const SizedBox( + child: ColumnLoadingPlaceholder(), + ) + else + ReviewList(reviewList, reviewCount), + const SizedBox(height: 48.0), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/presentation/views/restaurant_details/widgets/restaurant_data.dart b/lib/presentation/views/restaurant_details/widgets/restaurant_data.dart new file mode 100644 index 00000000..c6102e8d --- /dev/null +++ b/lib/presentation/views/restaurant_details/widgets/restaurant_data.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/presentation/common/restaurant_status/restaurant_status.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/spaced_divider.dart'; + +class RestaurantDetails extends StatelessWidget { + final Restaurant restaurant; + + const RestaurantDetails(this.restaurant, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${restaurant.price} ${restaurant.displayCategory}", + style: GoogleFonts.openSans(fontSize: 12.0), + ), + RestaurantStatus(restaurant.isOpen), + ], + ), + const SpacedDivider(), + Text( + "Address", + style: GoogleFonts.openSans(fontSize: 12.0), + ), + const SizedBox(height: 16.0), + Text( + restaurant.location?.formattedAddress ?? "", + style: GoogleFonts.openSans( + fontSize: 14.0, + fontWeight: FontWeight.w600, + ), + ), + const SpacedDivider(), + Text( + "Overall Rating", + style: GoogleFonts.openSans(fontSize: 12.0), + ), + const SizedBox(height: 16.0), + Row( + textBaseline: TextBaseline.alphabetic, + crossAxisAlignment: CrossAxisAlignment.baseline, + children: [ + Text( + restaurant.rating.toString(), + style: GoogleFonts.lora( + fontSize: 28.0, + fontWeight: FontWeight.w700, + textBaseline: TextBaseline.alphabetic, + ), + ), + Icon( + Icons.star, + color: Colors.amber[800], + size: 14.0, + semanticLabel: 'Rating', + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/views/restaurant_details/widgets/review.dart b/lib/presentation/views/restaurant_details/widgets/review.dart new file mode 100644 index 00000000..eab23fbf --- /dev/null +++ b/lib/presentation/views/restaurant_details/widgets/review.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/presentation/common/restaurant_rating/rating.dart'; + +class RestaurantReview extends StatelessWidget { + final Review review; + const RestaurantReview(this.review, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RestaurantRating( + review.rating, + size: 12.0, + ), + const SizedBox(height: 12.0), + Text( + review.text ?? "--------", + softWrap: true, + style: GoogleFonts.openSans( + fontSize: 16.0, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12.0), + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(review.user!.imageUrl ?? ""), + ), + const SizedBox(width: 4.0), + Text( + review.user?.name ?? "", + style: GoogleFonts.openSans( + fontSize: 12.0, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/presentation/views/restaurant_details/widgets/review_list.dart b/lib/presentation/views/restaurant_details/widgets/review_list.dart new file mode 100644 index 00000000..e7cfe756 --- /dev/null +++ b/lib/presentation/views/restaurant_details/widgets/review_list.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/review.dart'; +import 'package:restaurantour/presentation/views/restaurant_details/widgets/spaced_divider.dart'; + +class ReviewList extends StatelessWidget { + final List reviewList; + final int reviewCount; + const ReviewList(this.reviewList, this.reviewCount, {Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${reviewCount.toString()} Review${reviewCount > 1 ? 's' : ''}", + style: GoogleFonts.openSans( + fontSize: 12.0, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 16.0), + ...reviewList + .map( + (review) => [ + RestaurantReview( + review, + ), + const SpacedDivider(padding: 12.0), + ], + ) + .expand((i) => i) + .toList(), + ], + ), + ); + } +} diff --git a/lib/presentation/views/restaurant_details/widgets/spaced_divider.dart b/lib/presentation/views/restaurant_details/widgets/spaced_divider.dart new file mode 100644 index 00000000..40037b22 --- /dev/null +++ b/lib/presentation/views/restaurant_details/widgets/spaced_divider.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class SpacedDivider extends StatelessWidget { + final double padding; + const SpacedDivider({ + Key? key, + this.padding = 24, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox(height: padding), + const Divider(color: Colors.black26), + SizedBox(height: padding), + ], + ); + } +} diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart deleted file mode 100644 index f251d7b4..00000000 --- a/lib/repositories/yelp_repository.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:restaurantour/models/restaurant.dart'; - -const _apiKey = ''; - -class YelpRepository { - late Dio dio; - - YelpRepository({ - @visibleForTesting Dio? dio, - }) : dio = dio ?? - Dio( - BaseOptions( - baseUrl: 'https://api.yelp.com', - headers: { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/graphql', - }, - ), - ); - - /// Returns a response in this shape - /// { - /// "data": { - /// "search": { - /// "total": 5056, - /// "business": [ - /// { - /// "id": "faPVqws-x-5k2CQKDNtHxw", - /// "name": "Yardbird Southern Table & Bar", - /// "price": "$$", - /// "rating": 4.5, - /// "photos": [ - /// "https:///s3-media4.fl.yelpcdn.com/bphoto/_zXRdYX4r1OBfF86xKMbDw/o.jpg" - /// ], - /// "reviews": [ - /// { - /// "id": "sjZoO8wcK1NeGJFDk5i82Q", - /// "rating": 5, - /// "user": { - /// "id": "BuBCkWFNT_O2dbSnBZvpoQ", - /// "image_url": "https:///s3-media2.fl.yelpcdn.com/photo/v8tbTjYaFvkzh1d7iE-pcQ/o.jpg", - /// "name": "Gina T." - /// } - /// }, - /// { - /// "id": "okpO9hfpxQXssbTZTKq9hA", - /// "rating": 5, - /// "user": { - /// "id": "0x9xu_b0Ct_6hG6jaxpztw", - /// "image_url": "https:///s3-media3.fl.yelpcdn.com/photo/gjz8X6tqE3e4praK4HfCiA/o.jpg", - /// "name": "Crystal L." - /// } - /// }, - /// ... - /// ] - /// } - /// } - /// - Future getRestaurants({int offset = 0}) async { - try { - final response = await dio.post>( - '/v3/graphql', - data: _getQuery(offset), - ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); - } catch (e) { - return null; - } - } - - String _getQuery(int offset) { - return ''' -query getRestaurants { - search(location: "Las Vegas", limit: 20, offset: $offset) { - total - business { - id - name - price - rating - photos - reviews { - id - rating - user { - id - image_url - name - } - } - categories { - title - alias - } - hours { - is_open_now - } - location { - formatted_address - } - } - } -} -'''; - } -} diff --git a/lib/restaurantour.dart b/lib/restaurantour.dart new file mode 100644 index 00000000..f08b2c63 --- /dev/null +++ b/lib/restaurantour.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:restaurantour/logic/favourite_restaurants_bloc/favourite_restaurants_bloc.dart'; +import 'package:restaurantour/logic/restaurants_bloc/restaurants_bloc.dart'; +import 'package:restaurantour/presentation/views/home/home_page.dart'; + +import 'data/repositories/yelp_repository.dart'; + +class RestaurantTour extends StatelessWidget { + const RestaurantTour({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return RepositoryProvider( + create: (context) => YelpRepository(), + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RestaurantsBloc( + yelpRepository: RepositoryProvider.of(context), + )..add( + LoadRestaurants(), + ), + ), + BlocProvider( + lazy: false, + create: (context) => FavouriteRestaurantsBloc( + yelpRepository: RepositoryProvider.of(context), + ), + ), + ], + child: const RestauranTourView(), + ), + ); + } +} + +class RestauranTourView extends StatelessWidget { + const RestauranTourView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'RestauranTour', + debugShowCheckedModeBanner: false, + restorationScopeId: 'restauranttour', + theme: ThemeData( + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: const HomePage(), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0b052c68..315d4651 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: args - sha256: "0bd9a99b6eb96f07af141f0eb53eace8983e8e5aa5de59777aca31684680ef22" + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.5.0" async: dependency: transitive description: @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "165a6ec950d9252ebe36dc5335f2e6eb13055f33d56db0eeb7642768849b43d2" + url: "https://pub.dev" + source: hosted + version: "9.1.7" boolean_selector: dependency: transitive description: @@ -45,10 +61,10 @@ packages: dependency: transitive description: name: build - sha256: "3fbda25365741f8251b39f3917fb3c8e286a96fd068a5a242e11c2012d495777" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.4.1" build_config: dependency: transitive description: @@ -61,34 +77,34 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: f4d6244cc071ba842c296cb1c4ee1b31596b9f924300647ac7a1445493471a3f + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.3" + version: "7.3.0" built_collection: dependency: transitive description: @@ -101,10 +117,10 @@ packages: dependency: transitive description: name: built_value - sha256: b6c9911b2d670376918d5b8779bc27e0e612a94ec3ff0343689e991d8d0a3b8a + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.1.4" + version: "8.9.2" characters: dependency: transitive description: @@ -113,22 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - charcode: - dependency: transitive - description: - name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 - url: "https://pub.dev" - source: hosted - version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: dd007e4fb8270916820a0d66e24f619266b60773cddd082c6439341645af2659 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" clock: dependency: transitive description: @@ -149,26 +157,34 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.2" convert: dependency: transitive description: name: convert - sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "1.6.4" crypto: dependency: transitive description: name: crypto - sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" cupertino_icons: dependency: "direct main" description: @@ -185,14 +201,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: name: dio - sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3" + sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "5.4.3+1" fake_async: dependency: transitive description: @@ -201,27 +225,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" file: dependency: transitive description: name: file - sha256: b69516f2c26a5bcac4eee2e32512e1a5205ab312b3536c1c1227b2b942b5f9ad + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "7.0.0" fixnum: dependency: transitive description: name: fixnum - sha256: "6a2ef17156f4dc49684f9d99aaf4a93aba8ac49f5eac861755f5730ddf6e2e4e" + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2 + url: "https://pub.dev" + source: hosted + version: "8.1.5" flutter_lints: dependency: "direct dev" description: @@ -234,10 +274,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" url: "https://pub.dev" source: hosted - version: "2.0.9" + version: "2.0.10+1" flutter_test: dependency: "direct dev" description: flutter @@ -255,10 +295,18 @@ packages: dependency: transitive description: name: glob - sha256: "8321dd2c0ab0683a91a51307fa844c6db4aa8e3981219b78961672aaab434658" + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.2" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "2776c66b3e97c6cdd58d1bd3281548b074b64f1fd5c8f82391f7456e38849567" + url: "https://pub.dev" + source: hosted + version: "4.0.5" graphs: dependency: transitive description: @@ -267,38 +315,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + http: + dependency: transitive + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: bfb651625e251a88804ad6d596af01ea903544757906addcb2dcdf088b5ea185 + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185 + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + hydrated_bloc: + dependency: "direct main" + description: + name: hydrated_bloc + sha256: af35b357739fe41728df10bec03aad422cdc725a1e702e03af9d2a41ea05160c url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "9.1.5" io: dependency: transitive description: name: io - sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" js: dependency: transitive description: name: js - sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174 + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.6.7" json_annotation: dependency: "direct main" description: @@ -327,10 +399,10 @@ packages: dependency: transitive description: name: logging - sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.2.0" matcher: dependency: transitive description: @@ -351,26 +423,58 @@ packages: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.9.1" mime: dependency: transitive description: name: mime - sha256: fd5f81041e6a9fc9b9d7fa2cb8a01123f9f5d5d49136e06cb9dc7d33689529f4 + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: c4b5007d91ca4f67256e720cb1b6d704e79a510183a12fa551021f652577dce6 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: name: package_config - sha256: a4d5ede5ca9c3d88a2fef1147a078570c861714c806485c596b109819135bc12 + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.1.0" path: dependency: transitive description: @@ -387,54 +491,142 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "51f0d2c554cfbc9d6a312ab35152fc77e2f0b758ce9f1a444a3a1e5b8f3c6b7f" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" petitparser: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: name: pool - sha256: "05955e3de2683e1746222efd14b775df7131139e07695dc8e24650f6b4204504" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: name: pub_semver - sha256: b5a5fcc6425ea43704852ba4453ba94b08c2226c63418a260240c3a054579014 + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "3686efe4a4613a4449b1a4ae08670aadbd3376f2e78d93e3f8f0919db02a7256" + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.3" shelf: dependency: transitive description: name: shelf - sha256: c240984c924796e055e831a0a36db23be8cb04f170b26df572931ab36418421d + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: fd84910bf7d58db109082edf7326b75322b8f186162028482f53dc892f00332d + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.4" sky_engine: dependency: transitive description: flutter @@ -456,6 +648,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -468,26 +676,26 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" stream_transform: dependency: transitive description: name: stream_transform - sha256: ed464977cb26a1f41537e177e190c67223dbd9f4f683489b6ab2e5d211ec564e + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" string_scanner: dependency: transitive description: @@ -496,6 +704,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: @@ -504,54 +720,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + url: "https://pub.dev" + source: hosted + version: "1.24.3" test_api: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + url: "https://pub.dev" + source: hosted + version: "0.5.3" timing: dependency: transitive description: name: timing - sha256: c386d07d7f5efc613479a7c4d9d64b03710b03cfaa7e8ad5f2bfb295a1f0dfad + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" typed_data: dependency: transitive description: name: typed_data - sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee" + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.2" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "18f6690295af52d081f6808f2f7c69f0eed6d7e23a71539d75f4aeb8f0062172" + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "531d20465c10dfac7f5cd90b60bbe4dd9921f1ec4ca54c83ebb176dbacb7bb2d" + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "03012b0a33775c5530576b70240308080e1d5050f0faf000118c20e6463bc0ad" + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" url: "https://pub.dev" source: hosted - version: "1.1.9+2" + version: "1.1.11+1" vector_math: dependency: transitive description: @@ -560,46 +792,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + url: "https://pub.dev" + source: hosted + version: "11.10.0" watcher: dependency: transitive description: name: watcher - sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.1.0" web: dependency: transitive description: name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.1.4-beta" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0c2ada1b1aeb2ad031ca81872add6be049b8cb479262c6ad3c4b0f9c24eaab2f" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.3.0" yaml: dependency: transitive description: name: yaml - sha256: "3cee79b1715110341012d27756d9bae38e650588acd38d3f3c610822e1337ace" + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0-0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..24ea2371 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,11 +1,10 @@ name: restaurantour description: Flutter developer coding challenge starter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 - environment: sdk: ">=2.12.0 <3.0.0" @@ -16,6 +15,11 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_svg: ^2.0.9 + bloc: ^8.1.4 + hydrated_bloc: ^9.1.5 + path_provider: ^2.0.5 + flutter_bloc: ^8.1.5 + google_fonts: ^4.0.3 dev_dependencies: flutter_test: @@ -23,8 +27,12 @@ dev_dependencies: flutter_lints: ^1.0.2 build_runner: ^2.4.8 json_serializable: ^6.7.1 + mockito: ^5.4.4 + bloc_test: ^9.1.7 flutter: uses-material-design: true -# assets: -# - assets/svg/ \ No newline at end of file + assets: + - assets/yelp_restaurants_response.json + - assets/yelp_reviews_response.json + - assets/yelp_rest_status_response.json diff --git a/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.dart b/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.dart new file mode 100644 index 00000000..5a7614c5 --- /dev/null +++ b/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.dart @@ -0,0 +1,118 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; +import 'package:restaurantour/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.dart'; + +import 'restaurant_reviews_cubit.test.mocks.dart'; + +@GenerateMocks([YelpRepository]) +void main() { + group('RestaurantReviewsCubit Success Cases', () { + late MockYelpRepository mockYelpRepository; + + setUp(() { + mockYelpRepository = MockYelpRepository(); + }); + + blocTest( + 'emits [RestaurantReviewsLoading], [RestaurantReviewsData] and [RestaurantReviewsIdle] when loadReviews is successfully called.', + build: () { + when( + mockYelpRepository.getRestaurantReviews( + restaurantId: anyNamed('restaurantId'), + ), + ).thenAnswer( + (_) async => const ReviewQueryResult( + reviewsList: [ + Review(id: '1', text: 'Great food', rating: 5), + Review(id: '2', text: 'Awesome service', rating: 4), + ], + total: 2, + ), + ); + + return RestaurantReviewsCubit(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.loadReviews(restaurantId: '123'), + expect: () => [ + isA(), + isA().having( + (p0) => p0.reviewsList, + "List of Reviews", + [ + const Review(id: '1', text: 'Great food', rating: 5), + const Review(id: '2', text: 'Awesome service', rating: 4), + ], + ).having( + (p0) => p0.reviewCount, + "Reviews Count", + 2, + ), + isA(), + ], + ); + }); + + group('RestaurantReviewsCubit Error Cases', () { + late MockYelpRepository mockYelpRepository; + + setUpAll(() { + mockYelpRepository = MockYelpRepository(); + }); + + blocTest( + 'emits [RestaurantReviewsLoading], [RestaurantReviewsError] and [RestaurantReviewsIdle] when getRestaurantReviews throw DioException.', + build: () { + when( + mockYelpRepository.getRestaurantReviews( + restaurantId: anyNamed('restaurantId'), + ), + ).thenThrow( + DioException( + requestOptions: RequestOptions(), + message: "Failed to load reviews", + ), + ); + + return RestaurantReviewsCubit(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.loadReviews(restaurantId: '123'), + expect: () => [ + isA(), + isA().having( + (p0) => p0.errorMessage, + "Error message thrown by repository", + "Failed to load reviews", + ), + isA(), + ], + ); + + blocTest( + 'emits [RestaurantReviewsLoading], [RestaurantReviewsError] and [RestaurantReviewsIdle] when getRestaurantReviews throw Exception.', + build: () { + when( + mockYelpRepository.getRestaurantReviews( + restaurantId: anyNamed('restaurantId'), + ), + ).thenThrow(Exception("Failed to load reviews")); + + return RestaurantReviewsCubit(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.loadReviews(restaurantId: '123'), + expect: () => [ + isA(), + isA().having( + (p0) => p0.errorMessage, + "Error message thrown by repository", + "Exception: Failed to load reviews", + ), + isA(), + ], + ); + }); +} diff --git a/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.mocks.dart b/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.mocks.dart new file mode 100644 index 00000000..93707813 --- /dev/null +++ b/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.mocks.dart @@ -0,0 +1,158 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in restaurantour/test/logic/restaurant_reviews_cubit/restaurant_reviews_cubit.test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:dio/dio.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:restaurantour/data/models/restaurant.dart' as _i5; +import 'package:restaurantour/data/repositories/yelp_repository.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDio_0 extends _i1.SmartFake implements _i2.Dio { + _FakeDio_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeMockResponse_1 extends _i1.SmartFake implements _i3.MockResponse { + _FakeMockResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [YelpRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockYelpRepository extends _i1.Mock implements _i3.YelpRepository { + MockYelpRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Dio get dio => (super.noSuchMethod( + Invocation.getter(#dio), + returnValue: _FakeDio_0( + this, + Invocation.getter(#dio), + ), + ) as _i2.Dio); + + @override + set dio(_i2.Dio? _dio) => super.noSuchMethod( + Invocation.setter( + #dio, + _dio, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future<_i5.RestaurantQueryResult?> getRestaurants({int? offset = 0}) => + (super.noSuchMethod( + Invocation.method( + #getRestaurants, + [], + {#offset: offset}, + ), + returnValue: _i4.Future<_i5.RestaurantQueryResult?>.value(), + ) as _i4.Future<_i5.RestaurantQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantsResponse() => (super.noSuchMethod( + Invocation.method( + #mockRestaurantsResponse, + [], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantsResponse, + [], + ), + )), + ) as _i4.Future<_i3.MockResponse>); + + @override + _i4.Future<_i5.ReviewQueryResult?> getRestaurantReviews({ + required String? restaurantId, + int? offset = 0, + }) => + (super.noSuchMethod( + Invocation.method( + #getRestaurantReviews, + [], + { + #restaurantId: restaurantId, + #offset: offset, + }, + ), + returnValue: _i4.Future<_i5.ReviewQueryResult?>.value(), + ) as _i4.Future<_i5.ReviewQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantReviewsResponse() => + (super.noSuchMethod( + Invocation.method( + #mockRestaurantReviewsResponse, + [], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantReviewsResponse, + [], + ), + )), + ) as _i4.Future<_i3.MockResponse>); + + @override + _i4.Future<_i5.StatusQueryResult?> getRestaurantStatus( + String? restaurantId) => + (super.noSuchMethod( + Invocation.method( + #getRestaurantStatus, + [restaurantId], + ), + returnValue: _i4.Future<_i5.StatusQueryResult?>.value(), + ) as _i4.Future<_i5.StatusQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantStatusResponse( + String? restaurantId) => + (super.noSuchMethod( + Invocation.method( + #mockRestaurantStatusResponse, + [restaurantId], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantStatusResponse, + [restaurantId], + ), + )), + ) as _i4.Future<_i3.MockResponse>); +} diff --git a/test/logic/restaurants_bloc/restaurants_bloc.test.dart b/test/logic/restaurants_bloc/restaurants_bloc.test.dart new file mode 100644 index 00000000..2bf7d4a9 --- /dev/null +++ b/test/logic/restaurants_bloc/restaurants_bloc.test.dart @@ -0,0 +1,131 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/data/repositories/yelp_repository.dart'; +import 'package:restaurantour/logic/restaurants_bloc/restaurants_bloc.dart'; + +import 'restaurants_bloc.test.mocks.dart'; + +@GenerateMocks([YelpRepository]) +void main() { + group('RestaurantsBloc Success Cases', () { + late MockYelpRepository mockYelpRepository; + + setUp(() { + mockYelpRepository = MockYelpRepository(); + }); + + blocTest( + 'emits [RestaurantsLoading] and [RestaurantsData] when LoadRestaurants event is added.', + build: () { + when( + mockYelpRepository.getRestaurants(), + ).thenAnswer( + (_) async => const RestaurantQueryResult( + restaurants: [ + Restaurant( + id: 'jsfielsslfqxo0', + name: 'Great food Restaurant', + rating: 5.0, + ), + Restaurant( + id: 'uejdpqd911jdu8', + name: 'Gordons Service Food', + rating: 4.3, + ), + ], + total: 2, + ), + ); + + return RestaurantsBloc(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.add(LoadRestaurants()), + expect: () => [ + isA(), + isA().having( + (p0) => p0.restaurantsList, + "List of Restaurants", + const [ + Restaurant( + id: 'jsfielsslfqxo0', + name: 'Great food Restaurant', + rating: 5.0, + ), + Restaurant( + id: 'uejdpqd911jdu8', + name: 'Gordons Service Food', + rating: 4.3, + ), + ], + ), + ], + ); + }); + + group('RestaurantsBloc Error Cases', () { + late MockYelpRepository mockYelpRepository; + + setUp(() { + mockYelpRepository = MockYelpRepository(); + }); + + blocTest( + 'emits [RestaurantsLoading], [RestaurantsError] and [RestaurantsData] when LoadRestaurants event is added and getRestaurants throw DioException.', + build: () { + when( + mockYelpRepository.getRestaurants(), + ).thenThrow( + DioException( + requestOptions: RequestOptions(), + message: "Failed to load restaurants list", + ), + ); + + return RestaurantsBloc(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.add(LoadRestaurants()), + expect: () => [ + isA(), + isA().having( + (p0) => p0.errorMessage, + "Error message thrown by repository", + "Failed to load restaurants list", + ), + isA().having( + (p0) => p0.restaurantsList, + "List of restaurants", + [], + ), + ], + ); + + blocTest( + 'emits [RestaurantsLoading], [RestaurantsError] and [RestaurantsData] when LoadRestaurants event is added and getRestaurants throw Exception.', + build: () { + when( + mockYelpRepository.getRestaurants(), + ).thenThrow(Exception("Failed to load restaurants list")); + + return RestaurantsBloc(yelpRepository: mockYelpRepository); + }, + act: (bloc) => bloc.add(LoadRestaurants()), + expect: () => [ + isA(), + isA().having( + (p0) => p0.errorMessage, + "Error message thrown by repository", + "Exception: Failed to load restaurants list", + ), + isA().having( + (p0) => p0.restaurantsList, + "List of restaurants", + [], + ), + ], + ); + }); +} diff --git a/test/logic/restaurants_bloc/restaurants_bloc.test.mocks.dart b/test/logic/restaurants_bloc/restaurants_bloc.test.mocks.dart new file mode 100644 index 00000000..8ee6f4f7 --- /dev/null +++ b/test/logic/restaurants_bloc/restaurants_bloc.test.mocks.dart @@ -0,0 +1,158 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in restaurantour/test/logic/restaurants_bloc/restaurants_bloc.test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:dio/dio.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:restaurantour/data/models/restaurant.dart' as _i5; +import 'package:restaurantour/data/repositories/yelp_repository.dart' as _i3; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeDio_0 extends _i1.SmartFake implements _i2.Dio { + _FakeDio_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeMockResponse_1 extends _i1.SmartFake implements _i3.MockResponse { + _FakeMockResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [YelpRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockYelpRepository extends _i1.Mock implements _i3.YelpRepository { + MockYelpRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Dio get dio => (super.noSuchMethod( + Invocation.getter(#dio), + returnValue: _FakeDio_0( + this, + Invocation.getter(#dio), + ), + ) as _i2.Dio); + + @override + set dio(_i2.Dio? _dio) => super.noSuchMethod( + Invocation.setter( + #dio, + _dio, + ), + returnValueForMissingStub: null, + ); + + @override + _i4.Future<_i5.RestaurantQueryResult?> getRestaurants({int? offset = 0}) => + (super.noSuchMethod( + Invocation.method( + #getRestaurants, + [], + {#offset: offset}, + ), + returnValue: _i4.Future<_i5.RestaurantQueryResult?>.value(), + ) as _i4.Future<_i5.RestaurantQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantsResponse() => (super.noSuchMethod( + Invocation.method( + #mockRestaurantsResponse, + [], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantsResponse, + [], + ), + )), + ) as _i4.Future<_i3.MockResponse>); + + @override + _i4.Future<_i5.ReviewQueryResult?> getRestaurantReviews({ + required String? restaurantId, + int? offset = 0, + }) => + (super.noSuchMethod( + Invocation.method( + #getRestaurantReviews, + [], + { + #restaurantId: restaurantId, + #offset: offset, + }, + ), + returnValue: _i4.Future<_i5.ReviewQueryResult?>.value(), + ) as _i4.Future<_i5.ReviewQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantReviewsResponse() => + (super.noSuchMethod( + Invocation.method( + #mockRestaurantReviewsResponse, + [], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantReviewsResponse, + [], + ), + )), + ) as _i4.Future<_i3.MockResponse>); + + @override + _i4.Future<_i5.StatusQueryResult?> getRestaurantStatus( + String? restaurantId) => + (super.noSuchMethod( + Invocation.method( + #getRestaurantStatus, + [restaurantId], + ), + returnValue: _i4.Future<_i5.StatusQueryResult?>.value(), + ) as _i4.Future<_i5.StatusQueryResult?>); + + @override + _i4.Future<_i3.MockResponse> mockRestaurantStatusResponse( + String? restaurantId) => + (super.noSuchMethod( + Invocation.method( + #mockRestaurantStatusResponse, + [restaurantId], + ), + returnValue: _i4.Future<_i3.MockResponse>.value(_FakeMockResponse_1( + this, + Invocation.method( + #mockRestaurantStatusResponse, + [restaurantId], + ), + )), + ) as _i4.Future<_i3.MockResponse>); +} diff --git a/test/presentation/common/network_image_hero.test.dart b/test/presentation/common/network_image_hero.test.dart new file mode 100644 index 00000000..6508c447 --- /dev/null +++ b/test/presentation/common/network_image_hero.test.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/presentation/common/network_hero/network_image_hero.dart'; + +void main() { + setUpAll(() { + HttpOverrides.global = null; + }); + + testWidgets( + 'NetworkImageHero displays a hero with the provided hero tag', + (WidgetTester tester) async { + const String? imageLink = null; + const String heroId = 'hero_id'; + + await tester.pumpWidget( + const MaterialApp( + home: NetworkImageHero( + imageLink: imageLink, + heroId: heroId, + ), + ), + ); + + final heroWidget = find.byType(Hero); + expect(heroWidget, findsOneWidget); + final hero = tester.widget(heroWidget) as Hero; + expect(hero.tag, heroId); + }, + ); + + testWidgets( + 'NetworkImageHero displays Image from network when imageLink is provided', + (WidgetTester tester) async { + const String imageLink = + 'https://s3-media1.fl.yelpcdn.com/bphoto/V_zmwCUG1o_vR29xfkb-ng/o.jpg'; + const String heroId = 'hero_id'; + + await tester.pumpWidget( + const MaterialApp( + home: NetworkImageHero( + imageLink: imageLink, + heroId: heroId, + ), + ), + ); + + final imageWidget = find.byType(Image); + expect(imageWidget, findsOneWidget); + final image = tester.widget(imageWidget) as Image; + expect(image.image, isA()); + }, + ); + + testWidgets( + 'NetworkImageHero displays placeholder when imageLink is null', + (WidgetTester tester) async { + const String? imageLink = null; + const String heroId = 'hero_id'; + + await tester.pumpWidget( + const MaterialApp( + home: NetworkImageHero( + imageLink: imageLink, + heroId: heroId, + ), + ), + ); + + final container = find.byType(Container); + final icon = find.byIcon(Icons.restaurant_outlined); + + expect(container, findsOneWidget); + expect(icon, findsOneWidget); + }, + ); +} diff --git a/test/presentation/common/restaurant_card.test.dart b/test/presentation/common/restaurant_card.test.dart new file mode 100644 index 00000000..491693b4 --- /dev/null +++ b/test/presentation/common/restaurant_card.test.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:restaurantour/data/models/restaurant.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/restaurant_card.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/categories.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/image.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/price.dart'; +import 'package:restaurantour/presentation/common/restaurant_card/widgets/title.dart'; +import 'package:restaurantour/presentation/common/restaurant_rating/rating.dart'; +import 'package:restaurantour/presentation/common/restaurant_status/restaurant_status.dart'; + +abstract class OnTap { + void call(); +} + +class MockOnTap extends Mock implements OnTap {} + +void main() { + String id = '1'; + String name = 'Sample Restaurant'; + String price = '\$\$\$'; + double rating = 4.5; + List photos = [ + 'https://s3-media1.fl.yelpcdn.com/bphoto/V_zmwCUG1o_vR29xfkb-ng/o.jpg', + 'https://example.com/photo2.jpg', + ]; + List categories = [ + Category(title: 'Italian', alias: "Italian"), + Category(title: 'Pasta'), + ]; + List hours = const [ + Hours(isOpenNow: true), + Hours(isOpenNow: false), + ]; + List reviews = const [ + Review(id: '1', text: 'Great food', rating: 5), + Review(id: '2', text: 'Good service', rating: 4), + ]; + + late final Restaurant restaurant; + + setUpAll(() { + HttpOverrides.global = null; + + restaurant = Restaurant( + id: id, + name: name, + price: price, + rating: rating, + photos: photos, + categories: categories, + hours: hours, + reviews: reviews, + ); + }); + + testWidgets( + 'RestaurantCard should display key widgets', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: RestaurantCard( + restaurant, + onTap: () => {}, + ), + ), + ); + + final imageWidget = find.byType(RestaurantCardImage); + final titleWidget = find.byType(RestaurantCardName); + final priceWidget = find.byType(RestaurantCardPrice); + final categoriesWidget = find.byType(RestaurantCardCategories); + final ratingWidget = find.byType(RestaurantRating); + final statusWidget = find.byType(RestaurantStatus); + + expect(imageWidget, findsOneWidget); + expect(titleWidget, findsOneWidget); + expect(priceWidget, findsOneWidget); + expect(categoriesWidget, findsOneWidget); + expect(ratingWidget, findsOneWidget); + expect(statusWidget, findsOneWidget); + + final cardTitle = tester.widget(titleWidget) as RestaurantCardName; + expect(cardTitle.title, name); + + final cardPrice = tester.widget(priceWidget) as RestaurantCardPrice; + expect(cardPrice.price, price); + + final cardCategories = + tester.widget(categoriesWidget) as RestaurantCardCategories; + expect(cardCategories.categories, categories); + + final cardRating = tester.widget(ratingWidget) as RestaurantRating; + expect(cardRating.rating, restaurant.rating!.round()); + + final cardStatus = tester.widget(statusWidget) as RestaurantStatus; + expect(cardStatus.isOpenNow, restaurant.isOpen); + }, + ); + + testWidgets( + 'RestaurantCard should call onTap fn when tapped', + (WidgetTester tester) async { + final mockOnTap = MockOnTap(); + + await tester.pumpWidget( + MaterialApp( + home: RestaurantCard( + restaurant, + onTap: mockOnTap, + ), + ), + ); + + final cardWdgt = find.byType(Card); + + await tester.tap(cardWdgt); + + verify(mockOnTap()).called(1); + }, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 83fbeae4..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:restaurantour/main.dart'; - -void main() { - testWidgets('Page loads', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const Restaurantour()); - - // Verify that tests will run - expect(find.text('Fetch Restaurants'), findsOneWidget); - }); -}