Skip to content

Flutter Challenge - Camilo Rodriguez #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f2a1294
feat(app): initialize flutter project - env variable
kmil0 Sep 14, 2024
6155a12
chore(app): configure folder structure for clean architecture
kmil0 Sep 14, 2024
05a309a
feat(app): add models, usecases, domain and infrastructure layer
kmil0 Sep 14, 2024
dd3afb6
test(repository): add test for the gateway and domain layer
kmil0 Sep 15, 2024
f0f8277
feat(state): add provider package and create restaurant provider
kmil0 Sep 16, 2024
c4de698
feat(ui) add ui colors,sizes, typography and restructure folders
kmil0 Sep 16, 2024
8a0aece
feat(ui) add home page and homeappbar, update colors palette
kmil0 Sep 16, 2024
ca44256
feat(ui) add availability, image and rating widget
kmil0 Sep 16, 2024
30a0068
test(ui) add test to availability widget
kmil0 Sep 16, 2024
92d9c16
feat(ui) add restaurant list page
kmil0 Sep 16, 2024
6a0db6c
test(ui): add test to restaurant list widget
kmil0 Sep 16, 2024
57d46a6
feat(ui): restaurant detail page layout
kmil0 Sep 16, 2024
bf904e6
feat(ui): add review list widget and refactor restaurant detail page …
kmil0 Sep 16, 2024
0736d12
feat(favorites): add provider for storing favorite restaurants client…
kmil0 Sep 16, 2024
905f02e
test(providers): add tests for favorites and restaurant providers
kmil0 Sep 16, 2024
6594086
feat(ui): implement favorite restaurant provider
kmil0 Sep 16, 2024
bfd8e74
chore: add coverage ignore for constants and models
kmil0 Sep 16, 2024
b0ceaac
feat: add shared_preferences package for local storage
kmil0 Sep 16, 2024
a618559
test(golden): add golden test for UI home and restaurant pages
kmil0 Sep 16, 2024
1e7e543
test(integration): add integration test for navigating to RestaurantD…
kmil0 Sep 17, 2024
2f2ec57
Remove .env from Git tracking and add to .gitignore
kmil0 Sep 17, 2024
187c88f
add assets to readme
kmil0 Sep 17, 2024
baf0c11
refactor(provider): update FavoritesProvider to use LocalStorageGateway
kmil0 Sep 17, 2024
0b8a55c
Update README.md
kmil0 Sep 17, 2024
431a26a
refactor: add pagination to restaurant list
kmil0 Sep 17, 2024
d99cc28
add. env to gitignore
kmil0 Sep 17, 2024
2c0ae54
Merge branch 'feature/code_challenge' of github.com:kmil0/flutter_tes…
kmil0 Sep 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .fvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"flutter": "3.22.3",
"flavors": {}
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
.pub-cache/
.pub/
/build/
.env

# Web related
lib/generated_plugin_registrant.dart
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,54 @@
# Restaurant Tour

## Overview

As part of the solution to the Superformula challenge, I implemented **Clean Architecture** by separating the project into layers: domain, infrastructure, and UI. I also used the **Provider** package for state management and **Shared Preferences** to store favorite items locally. Additionally, I incorporated various testing approaches, including unit, widget, golden, and integration tests. The UI design follows the principles of atomic design.
![App Demo](lib/screenshots/restaurant_tour_demo.gif)

<img src="screenshots/restaurant_tour_demo.gif" width="306" height="617">

## Technologies and Packages Used

- **Flutter**: Core framework for building the mobile application.
- **Provider**: Used for state management, allowing reactive UI updates and separation of business logic.
- **Shared Preferences**: To persist local data such as favorite restaurants across app sessions.
- **Mocktail**: To mock API responses during development and testing.
- **Dio**: HTTP client for making API requests.
- **Integration, widget, Unit Testing (golden)**: Mocks and test utilities for thoroughly testing features and API interactions.

## Project Structure

The app is organized into three main layers:
package structure

<img src="screenshots/folders.png" width="276" height="497">

### 1. **Domain Layer**
- **Entities**: Defines the core business objects such as `RestaurantEntity`.
- **Use Cases**: Contains the business logic. Example: Fetching restaurants or marking a restaurant as a favorite.
- **Repositories**: Interfaces that act as contracts for the infrastructure layer.

### 2. **Infrastructure Layer**
- **Data Sources**: API integrations and local data handling using Shared Preferences.
- **Mappers**: Translates data between different layers (e.g., API model to domain entities).
- **Repositories Implementations**: Concrete implementations of domain repositories using data sources.

### 3. **UI Layer**
- **Widgets**: Flutter UI components such as `RestaurantListPage`, `FavoritesRestaurantsPage`, and `RestaurantDetailsPage`.
- **State Management**: Handled by `RestaurantProvider` and `FavoritesProvider`, allowing efficient data handling and UI updates.
- **Utilities**: Helpers for managing colors, styles, and constants throughout the app.

## Yelp API Configuration

To ensure the app works correctly, you need to configure the Yelp API key. Follow these steps:

1. Create a `.env` file in the root of the project.
2. Add your Yelp API key to the `.env` file with the following format:

```bash
YELP_API_KEY=your_api_key_here
```
##
Welcome to Superformula's Coding challenge, we are excited to see what you can build!

This take home test aims to evaluate your skills in building a Flutter application. We are looking for a well-structured and well-tested application that demonstrates your knowledge of Flutter and the Dart language.
Expand Down
3 changes: 3 additions & 0 deletions assets/images/star.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions integration_test/app_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:provider/provider.dart';
import 'package:mocktail/mocktail.dart';
import 'package:restaurant_tour/config/providers/favorites_provider.dart';
import 'package:restaurant_tour/config/providers/restaurant_providers.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart';
import 'package:restaurant_tour/domain/usecase/restaurant/local_storage_use_case.dart';
import 'package:restaurant_tour/infrastructure/driven_adapters/api/local_storage_api.dart';
import 'package:restaurant_tour/ui/pages/home/widgets/card_item.dart';
import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart';
import 'package:shared_preferences/shared_preferences.dart';

class MockRestaurantGateway extends Mock implements RestaurantGateway {}

class MockLocalStorageUseCase extends Mock implements LocalStorageUseCase {}

class MockRestaurantProvider extends RestaurantProvider {
late List<RestaurantEntity> _restaurants = [];
bool _isLoading = true;
MockRestaurantProvider(RestaurantGateway restaurantGateway) : super(restaurantGateway: restaurantGateway);

@override
List<RestaurantEntity>? get restaurants => _restaurants;

@override
bool get isLoading => _isLoading;

@override
Future<void> getRestaurants({int offset = 0}) async {
_restaurants = [
RestaurantEntity(
id: '1',
name: 'Test Restaurant',
price: '\$\$',
rating: 4.5,
categories: [Category(title: 'American')],
photos: ['https://example.com/photo.jpg'],
hours: [Hours(isOpenNow: true)],
reviews: [],
location: Location(formattedAddress: '123 Test St.'),
),
];
_isLoading = false;
notifyListeners();
}
}

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
registerFallbackValue(
RestaurantEntity(
id: '0',
name: 'Test Restaurant',
price: '',
rating: 0.0,
categories: [],
photos: [],
hours: [],
reviews: [],
location: Location(formattedAddress: ''),
),
);
});
testWidgets(
'GIVEN restaurants WHEN the app calls getRestaurants THEN the app shows the list of restaurants and user can tap to open detail restaurant',
(WidgetTester tester) async {
final mockRestaurantGateway = MockRestaurantGateway();
final mockLocalStorageUseCase = MockLocalStorageUseCase();
when(() => mockLocalStorageUseCase.getFavoriteRestaurants()).thenAnswer((_) async => []);

when(() => mockLocalStorageUseCase.addFavoriteRestaurant(any())).thenAnswer((_) async => Future.value());

when(() => mockLocalStorageUseCase.deleteFavoriteRestaurant(any())).thenAnswer((_) async => Future.value());
final prefs = await SharedPreferences.getInstance();

await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<RestaurantProvider>(
create: (_) => MockRestaurantProvider(mockRestaurantGateway),
),
ChangeNotifierProvider(
create: (_) => FavoritesProvider(localStorageGateway: LocalStorageApi(prefs: prefs)),
),
],
child: const MaterialApp(
home: Scaffold(
body: RestaurantListPage(),
),
),
),
);

await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<RestaurantProvider>(
create: (_) => MockRestaurantProvider(mockRestaurantGateway),
),
ChangeNotifierProvider(
create: (_) => FavoritesProvider(localStorageGateway: LocalStorageApi(prefs: prefs)),
),
],
child: const MaterialApp(
home: Scaffold(
body: RestaurantListPage(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(CardItem), findsOneWidget);

final firstRestaurant = find.byType(CardItem).first;
await tester.tap(firstRestaurant);
await tester.pumpAndSettle();

expect(find.text('Test Restaurant'), findsOneWidget);
await tester.pumpAndSettle();

final backIcon = find.byIcon(Icons.arrow_back);
await tester.tap(backIcon);
await tester.pumpAndSettle();

expect(find.byType(CardItem), findsOneWidget);
});
}
21 changes: 21 additions & 0 deletions lib/config/constants/constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// coverage:ignore-file
class AppConstants {
AppConstants._();

static const String allRestaurants = 'All restaurants';
static const String appName = 'RestauranTour';
static const String env = '.env';
static const String loading = 'Loading...';
static const String myFavorites = 'My Favorites';
static const String error = 'error';
static const String noRestaurantsAvailable = 'No restaurants available';
static const String noData = 'No Data';
static const String openNow = 'Open Now';
static const String closed = 'Closed';
static const String rating = 'Rating:';
static const String addres = 'Address';
static const String overrallRating = 'Overall Rating';
static const String reviews = 'Reviews';
static const String noFavoriteRestaurants = 'No favorite restaurants';
static const String keyFavoriteRestaurants = 'favoriteRestaurants';
}
23 changes: 23 additions & 0 deletions lib/config/environment.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

final class Environment {
static const String _baseUrl = 'https://api.yelp.com/';
static final String _apiKey = dotenv.env['API_KEY_YELP'] ?? '';

static Dio baseDioClient({String? url, String? key}) {
final baseUrl = url ?? _baseUrl;
final apiKey = key ?? _apiKey;

return Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {
'Authorization': 'Bearer $apiKey',
'Content-type': 'application/graphql',
'Accept': 'application/json',
},
),
);
}
}
38 changes: 38 additions & 0 deletions lib/config/providers/favorites_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/local_storage_gateway.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart';
import 'package:restaurant_tour/domain/usecase/restaurant/local_storage_use_case.dart';

class FavoritesProvider extends ChangeNotifier {
final LocalStorageGatewayInterface localStorageGateway;
final LocalStorageUseCase localStorageUseCase;

List<RestaurantEntity> _favoriteRestaurants = [];

FavoritesProvider({required this.localStorageGateway})
: localStorageUseCase = LocalStorageUseCase(localStorageGatewayInterface: localStorageGateway) {
_loadFavorites();
}

List<RestaurantEntity> get favoriteRestaurants => _favoriteRestaurants;

Future<void> _loadFavorites() async {
_favoriteRestaurants = (await localStorageUseCase.getFavoriteRestaurants()) ?? [];
notifyListeners();
}

Future<void> toggleFavorite(RestaurantEntity restaurant) async {
if (isFavorite(restaurant.id)) {
await localStorageUseCase.deleteFavoriteRestaurant(restaurant.id);
_favoriteRestaurants.removeWhere((r) => r.id == restaurant.id);
} else {
await localStorageUseCase.addFavoriteRestaurant(restaurant);
_favoriteRestaurants.add(restaurant);
}
notifyListeners();
}

bool isFavorite(String restaurantId) {
return _favoriteRestaurants.any((r) => r.id == restaurantId);
}
}
56 changes: 56 additions & 0 deletions lib/config/providers/restaurant_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_entity.dart';
import 'package:restaurant_tour/domain/models/restaurant/gateway/restaurant_gateway.dart';
import 'package:restaurant_tour/domain/usecase/restaurant/restaurant_use_case.dart';

class RestaurantProvider extends ChangeNotifier {
final RestaurantGateway restaurantGateway;
final RestaurantUseCase restaurantUseCase;

RestaurantProvider({required this.restaurantGateway})
: restaurantUseCase = RestaurantUseCase(restaurantGateway: restaurantGateway);

List<RestaurantEntity>? _restaurants = [];
List<RestaurantEntity>? get restaurants => _restaurants;

bool _isLoading = false;
bool get isLoading => _isLoading;

String? _errorMessage;
String? get errorMessage => _errorMessage;

int _offset = 0;
bool _hasMore = true;
bool get hasMore => _hasMore;

Future<void> getRestaurants({int offset = 0}) async {
if (_isLoading || !_hasMore) return;
_isLoading = true;
_errorMessage = null;
notifyListeners();

try {
final result = await restaurantUseCase.fetchRestaurants(offset: offset);
if (result == null || result.isEmpty) {
_hasMore = false;
} else {
if (offset == 0) {
_restaurants = result;
} else {
_restaurants?.addAll(result);
}
_offset += result.length;
}
} catch (e) {
_errorMessage = 'Error retrieving restaurants: $e';
if (_offset == 0) _restaurants = null;
} finally {
_isLoading = false;
notifyListeners();
}
}

Future<void> loadMoreRestaurants() async {
await getRestaurants(offset: _offset);
}
}
36 changes: 36 additions & 0 deletions lib/config/routes/app_routes.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:restaurant_tour/ui/pages/home/home_page.dart';
import 'package:restaurant_tour/ui/pages/detail_restaurant/restaurant_details_page.dart';
import 'package:restaurant_tour/ui/pages/restaurants/restaurant_list_page.dart';

class AppRoutes {
AppRoutes._(); // Private constructor to prevent instantiation.
static const String home = '/';
static const String restaurantList = '/restaurantList';
static const String restaurantDetails = '/restaurantDetails';
}

class RouterManager {
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case AppRoutes.home:
return MaterialPageRoute(builder: (_) => const HomePage());
case AppRoutes.restaurantList:
return MaterialPageRoute(builder: (_) => const RestaurantListPage());
case AppRoutes.restaurantDetails:
return MaterialPageRoute(builder: (_) => const RestaurantDetailsPage());
default:
return _errorRoute();
}
}

static Route<dynamic> _errorRoute() {
return MaterialPageRoute(
builder: (_) => const Scaffold(
body: Center(
child: Text('Page cannot be found'),
),
),
);
}
}
2 changes: 1 addition & 1 deletion lib/query.dart → lib/config/utils/restaurants_query.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
String query(int offset) => '''
String restaurantsQuery(int offset) => '''
query getRestaurants {
search(location: "Las Vegas", limit: 20, offset: $offset) {
total
Expand Down
Loading