diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..05b430ce --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.13.9" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1be2d875..35858984 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ app.*.map.json /android/app/release # fvm -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# env +*.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f285aa4a..257f03fb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "dart.lineLength": 120, "dart.flutterSdkPath": ".fvm/flutter_sdk", "search.exclude": { "**/.fvm": true diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 00000000..2f814751 --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,205 @@ +SF:lib/models/restaurant.dart +DA:10,0 +DA:15,0 +DA:16,0 +DA:18,0 +DA:26,0 +DA:30,0 +DA:32,0 +DA:42,0 +DA:48,0 +DA:50,0 +DA:59,0 +DA:65,0 +DA:67,0 +DA:75,0 +DA:79,0 +DA:80,0 +DA:82,0 +DA:97,1 +DA:109,0 +DA:110,0 +DA:112,0 +DA:115,0 +DA:116,0 +DA:117,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:146,0 +DA:151,0 +DA:152,0 +DA:154,0 +LF:34 +LH:1 +end_of_record +SF:lib/models/restaurant.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:19,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:53,0 +DA:54,0 +DA:57,0 +DA:58,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:77,0 +DA:79,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:95,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:104,0 +DA:106,0 +DA:107,0 +DA:108,0 +LF:69 +LH:0 +end_of_record +SF:lib/app_state.dart +DA:5,3 +DA:7,1 +DA:8,1 +DA:11,1 +DA:14,2 +DA:15,0 +DA:16,0 +DA:17,0 +DA:20,1 +DA:21,1 +DA:22,2 +DA:23,1 +DA:26,1 +DA:27,6 +DA:28,2 +DA:29,1 +DA:32,1 +DA:34,6 +DA:35,2 +LF:19 +LH:16 +end_of_record +SF:lib/widgets/star_rating.dart +DA:8,1 +DA:12,0 +DA:14,1 +DA:16,2 +DA:17,1 +DA:19,1 +DA:21,2 +DA:23,1 +DA:24,1 +LF:9 +LH:8 +end_of_record +SF:lib/resources/color_manager.dart +DA:4,0 +DA:5,0 +DA:6,0 +DA:7,0 +DA:8,0 +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:28,0 +DA:29,3 +DA:30,0 +DA:32,0 +DA:34,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:43,0 +DA:44,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:59,1 +DA:60,1 +DA:61,2 +DA:62,1 +DA:64,3 +LF:47 +LH:6 +end_of_record +SF:lib/utils/helper_utils.dart +DA:2,1 +DA:3,1 +DA:6,1 +LF:3 +LH:3 +end_of_record diff --git a/lib/app_state.dart b/lib/app_state.dart new file mode 100644 index 00000000..7dd26f4e --- /dev/null +++ b/lib/app_state.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/models/restaurant.dart'; + +class AppState extends ChangeNotifier { + static final AppState _instance = AppState._internal(); + + factory AppState() { + return _instance; + } + + AppState._internal(); + + List _favorites = []; + List get favorites => _favorites; + set favorites(List options) { + _favorites = options; + notifyListeners(); + } + + void addFavorite(Restaurant restaurant) { + if (isFavorite(restaurant)) return; + _favorites.add(restaurant); + notifyListeners(); + } + + void removeFavorite(Restaurant restaurant) { + final index = _favorites.indexWhere((element) => element.id == restaurant.id); + _favorites.removeAt(index); + notifyListeners(); + } + + bool isFavorite(Restaurant? restaurant) { + if (restaurant == null) return false; + final index = _favorites.indexWhere((element) => element.id == restaurant.id); + return index != -1; + } +} diff --git a/lib/main.dart b/lib/main.dart index c6ce7473..e9047c17 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,56 +1,34 @@ import 'package:flutter/material.dart'; -import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurantour/app_state.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; +import 'package:restaurantour/resources/routes_manager.dart'; +import 'package:restaurantour/resources/theme_manager.dart'; -void main() { - runApp(const Restaurantour()); +void main() async { + final appState = AppState(); // Initialize FFAppState + await dotenv.load(); + runApp( + MultiProvider( + providers: [ChangeNotifierProvider(create: (context) => appState)], + child: 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(), - ); - } -} - -class HomePage extends StatelessWidget { - const HomePage({Key? key}) : super(key: key); - - @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(); - - 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'); - } - }, - ), - ], - ), + return ScreenUtilInit( + builder: (context, child) => MaterialApp( + title: AppStrings.appName, + theme: getApplicationTheme(false, context), + onGenerateRoute: RouteGenerator.getRoute, + initialRoute: Routes.homeRoute, + debugShowCheckedModeBanner: false, ), ); } diff --git a/lib/repositories/yelp_repository.dart b/lib/repositories/yelp_repository.dart index f251d7b4..c18b1396 100644 --- a/lib/repositories/yelp_repository.dart +++ b/lib/repositories/yelp_repository.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:restaurantour/models/restaurant.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:restaurantour/utils/mock_data.dart'; -const _apiKey = ''; +String? _apiKey = dotenv.env['YELP_API_KEY']; class YelpRepository { late Dio dio; @@ -64,12 +67,27 @@ class YelpRepository { '/v3/graphql', data: _getQuery(offset), ); - return RestaurantQueryResult.fromJson(response.data!['data']['search']); + final data = response.data!['data']['search']; + + // final data = await fakeApiCall(); + return RestaurantQueryResult.fromJson(data); } catch (e) { return null; } } + Future> fakeApiCall() async { + await Future.delayed(const Duration(seconds: 1)); + + try { + Map parsed = jsonDecode(MockData.yelpResponse); + return parsed; + } catch (e) { + print('Fake api call failed: $e'); + return {}; + } + } + String _getQuery(int offset) { return ''' query getRestaurants { diff --git a/lib/resources/color_manager.dart b/lib/resources/color_manager.dart new file mode 100644 index 00000000..20a42ec4 --- /dev/null +++ b/lib/resources/color_manager.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ColorManager { + static Color grey1 = HexaColor.fromHex("#525252"); + static Color grey2 = HexaColor.fromHex("#737477"); + static Color white = HexaColor.fromHex("#FFFFFF"); + static Color black = HexaColor.fromHex("#000000"); + + static Color black5 = HexaColor.fromHex("#F9F9FA"); + static Color black80 = HexaColor.fromHex("#1E2135"); + static Color black60 = HexaColor.fromHex("#A0A4A8"); + static Color black50 = HexaColor.fromHex("#99000000"); + static Color black30 = HexaColor.fromHex("#4D000000"); + static Color black20 = HexaColor.fromHex("#33000000"); + static Color black10 = HexaColor.fromHex("#1A000000"); + + static Color success = HexaColor.fromHex("#00F033"); + static Color error = HexaColor.fromHex("#FB4E4E"); + static Color blue = HexaColor.fromHex('#1F1B2F'); + static Color yellow = HexaColor.fromHex("#ECD037"); +} + +extension HexaColor on Color { + static Color fromHex(String hexColorString) { + hexColorString = hexColorString.replaceAll('#', ''); + if (hexColorString.length == 6) { + hexColorString = "FF$hexColorString"; // 8 char with opacity 100% + } + return Color(int.parse("0x$hexColorString")); + } +} diff --git a/lib/resources/font_manager.dart b/lib/resources/font_manager.dart new file mode 100644 index 00000000..ca8fbc7e --- /dev/null +++ b/lib/resources/font_manager.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class FontConstant{ + static const String fontFamily = "Montserrat"; +} + +class FontWeightManager{ + static const FontWeight light = FontWeight.w300; + static const FontWeight regular = FontWeight.w400; + static const FontWeight medium = FontWeight.w500; + static const FontWeight semiBold = FontWeight.w600; + static const FontWeight bold = FontWeight.w700; +} + +class FontSize{ + static const double s12 = 12.0; + static const double s14 = 14.0; + static const double s16 = 16.0; + static const double s17 = 17.0; + static const double s18 = 18.0; + static const double s20 = 20.0; + static const double s24 = 24.0; + static const double s28 = 28.0; +} \ No newline at end of file diff --git a/lib/resources/resources_exports.dart b/lib/resources/resources_exports.dart new file mode 100644 index 00000000..cda77d6a --- /dev/null +++ b/lib/resources/resources_exports.dart @@ -0,0 +1,6 @@ +export '/resources/color_manager.dart'; +export '/resources/font_manager.dart'; +export '/resources/strings_manager.dart'; +export '/resources/styles_manager.dart'; +export '/resources/values_manager.dart'; +export 'package:flutter_screenutil/flutter_screenutil.dart'; diff --git a/lib/resources/routes_manager.dart b/lib/resources/routes_manager.dart new file mode 100644 index 00000000..2f6107ca --- /dev/null +++ b/lib/resources/routes_manager.dart @@ -0,0 +1,62 @@ +import 'package:restaurantour/screens/restaurant_detail.dart'; +import 'package:restaurantour/screens/restaurants_screen.dart'; + +import '/resources/strings_manager.dart'; +import 'package:flutter/material.dart'; + +class Routes { + static const String homeRoute = "/"; + static const String restaurantDetailRoute = "/restaurantDetail"; +} + +class RouteGenerator { + static PageRouteBuilder slideTransitionTo( + Widget screen, { + RouteSettings? routeSettings, + }) { + return PageRouteBuilder( + settings: routeSettings, + pageBuilder: (_, __, ___) => screen, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + ); + } + + static PageRouteBuilder fadeTransitionTo(Widget screen) { + return PageRouteBuilder( + pageBuilder: (_, __, ___) => screen, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ); + } + + static Route getRoute(RouteSettings routeSettings) { + switch (routeSettings.name) { + case Routes.homeRoute: + return slideTransitionTo(const RestaurantsScreen()); + case Routes.restaurantDetailRoute: + return slideTransitionTo(const RestaurantDetail(), routeSettings: routeSettings); + + default: + return unDefinedRoute(); + } + } + + static Route unDefinedRoute() { + return MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar( + title: const Text(AppStrings.noRouteFound), + ), + body: const Center( + child: Text(AppStrings.noRouteFound), + ), + ), + ); + } +} diff --git a/lib/resources/strings_manager.dart b/lib/resources/strings_manager.dart new file mode 100644 index 00000000..2e13bf86 --- /dev/null +++ b/lib/resources/strings_manager.dart @@ -0,0 +1,15 @@ +class AppStrings { + static const String appName = "RestauranTour"; + static const String noRouteFound = "Not route found"; + static const String noData = "No data found"; + static const String emptyList = "List is empty"; + static const String allRestaurants = "All Restaurants"; + static const String myFavorites = "My Favorites"; + static const String isOpen = "Open Now"; + static const String isClosed = "Closed"; + static const String restaurantDetail = "Restaurant Detail"; + static const String address = "Address"; + static const String overallRating = "Overall Rating"; + static const String review = "Review"; + static const String reviews = "Reviews"; +} diff --git a/lib/resources/styles_manager.dart b/lib/resources/styles_manager.dart new file mode 100644 index 00000000..62d9e4fa --- /dev/null +++ b/lib/resources/styles_manager.dart @@ -0,0 +1,26 @@ +import '/resources/font_manager.dart'; +import 'package:flutter/material.dart'; + +TextStyle _getTextStyle(double fontSize, String fontFamily, FontWeight fontWeight, Color color) { + return TextStyle(fontSize: fontSize, fontFamily: fontFamily, fontWeight: fontWeight, color: color); +} + +TextStyle getLightTextStyle({double fontSize = FontSize.s12, required Color color}) { + return _getTextStyle(fontSize, FontConstant.fontFamily, FontWeightManager.light, color); +} + +TextStyle getRegularTextStyle({double fontSize = FontSize.s12, required Color color}) { + return _getTextStyle(fontSize, FontConstant.fontFamily, FontWeightManager.regular, color); +} + +TextStyle getMediumTextStyle({double fontSize = FontSize.s12, required Color color}) { + return _getTextStyle(fontSize, FontConstant.fontFamily, FontWeightManager.medium, color); +} + +TextStyle getSemiBoldTextStyle({double fontSize = FontSize.s12, required Color color}) { + return _getTextStyle(fontSize, FontConstant.fontFamily, FontWeightManager.semiBold, color); +} + +TextStyle getBoldTextStyle({double fontSize = FontSize.s12, required Color color}) { + return _getTextStyle(fontSize, FontConstant.fontFamily, FontWeightManager.bold, color); +} diff --git a/lib/resources/theme_manager.dart b/lib/resources/theme_manager.dart new file mode 100644 index 00000000..0e73dc22 --- /dev/null +++ b/lib/resources/theme_manager.dart @@ -0,0 +1,123 @@ +import 'package:flutter/cupertino.dart'; + +import '/resources/resources_exports.dart'; +import 'package:flutter/material.dart'; + +ThemeData getApplicationTheme(bool darkModeOn, BuildContext context) { + return ThemeData( + //main colors + brightness: (darkModeOn) ? Brightness.dark : Brightness.light, + primaryColor: ColorManager.white, + primaryColorDark: ColorManager.black60, + primaryColorLight: ColorManager.black5, + disabledColor: ColorManager.grey1, + indicatorColor: ColorManager.black, + + //card theme + cardTheme: CardTheme( + color: ColorManager.white, + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSize.size8.r)), + ), + //appbar theme + appBarTheme: AppBarTheme( + centerTitle: true, + color: ColorManager.white, + elevation: 4, + shadowColor: ColorManager.black20, + titleTextStyle: getRegularTextStyle( + fontSize: FontSize.s16, + color: ColorManager.black, + ), + ), + //scaffold theme + scaffoldBackgroundColor: (darkModeOn) ? ColorManager.black : ColorManager.black5, + //button theme + buttonTheme: ButtonThemeData( + disabledColor: ColorManager.grey1, + buttonColor: ColorManager.black, + shape: StadiumBorder( + side: BorderSide(color: ColorManager.white, width: 2.w), + ), + ), + //elevated button theme + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + textStyle: getRegularTextStyle(color: ColorManager.black80), + backgroundColor: ColorManager.black, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + backgroundColor: ColorManager.black, + foregroundColor: ColorManager.black80, + ), + ), + //text theme + textTheme: TextTheme( + displayLarge: getSemiBoldTextStyle( + color: ColorManager.black80, + fontSize: FontSize.s16.sp, + ), + titleMedium: getMediumTextStyle( + color: ColorManager.black60, + fontSize: FontSize.s14.sp, + ), + bodySmall: getRegularTextStyle(color: ColorManager.grey1), + bodyLarge: getRegularTextStyle(color: ColorManager.grey1), + ), + //input decoration theme + inputDecorationTheme: InputDecorationTheme( + hintStyle: getLightTextStyle( + color: ColorManager.black60, + fontSize: FontSize.s14.sp, + ), + labelStyle: getRegularTextStyle( + color: ColorManager.black80, + fontSize: FontSize.s16.sp, + ), + errorStyle: getLightTextStyle(color: ColorManager.error), + filled: true, + fillColor: ColorManager.black5, + disabledBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(16.r)), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(16.r)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(16.r)), + ), + errorBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(16.r)), + ), + focusedErrorBorder: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(16.r)), + ), + ), + //slider theme + sliderTheme: SliderTheme.of(context).copyWith( + trackHeight: 4.0, + thumbColor: ColorManager.black, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10.r), + overlayColor: null, + activeTickMarkColor: ColorManager.black5, + activeTrackColor: ColorManager.black5, + inactiveTickMarkColor: ColorManager.black80, + inactiveTrackColor: ColorManager.black80, + ), + //icon theme + iconTheme: IconThemeData( + color: ColorManager.black, + ), + // circular progress indicator theme + cupertinoOverrideTheme: CupertinoThemeData( + primaryColor: ColorManager.black, + ), + ); +} diff --git a/lib/resources/values_manager.dart b/lib/resources/values_manager.dart new file mode 100644 index 00000000..790c0a8a --- /dev/null +++ b/lib/resources/values_manager.dart @@ -0,0 +1,45 @@ +class AppMargin { + static const double margin8 = 8.0; + static const double margin10 = 10.0; + static const double margin12 = 12.0; + static const double margin14 = 14.0; + static const double margin16 = 16.0; + static const double margin18 = 18.0; + static const double margin20 = 20.0; +} + +class AppPadding { + static const double padding4 = 4.0; + static const double padding5 = 5.0; + static const double padding8 = 8.0; + static const double padding10 = 10.0; + static const double padding12 = 12.0; + static const double padding14 = 14.0; + static const double padding16 = 16.0; + static const double padding18 = 18.0; + static const double padding20 = 20.0; + static const double padding24 = 24.0; + static const double padding32 = 32.0; +} + +class AppSize { + static const double size0 = 0; + static const double size1 = 1.0; + static const double size2 = 2.0; + static const double size4 = 4.0; + static const double size8 = 8.0; + static const double size12 = 12.0; + static const double size14 = 14.0; + static const double size16 = 16.0; + static const double size18 = 18.0; + static const double size20 = 20.0; + static const double size22 = 22.0; + static const double size24 = 24.0; + static const double size28 = 28.0; + static const double size32 = 32.0; + static const double size36 = 36.0; + static const double size48 = 48.0; + static const double size56 = 56.0; + static const double size60 = 60.0; + static const double size80 = 80.0; +} diff --git a/lib/screens/restaurant_detail.dart b/lib/screens/restaurant_detail.dart new file mode 100644 index 00000000..6f2dda0b --- /dev/null +++ b/lib/screens/restaurant_detail.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/app_state.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; +import 'package:restaurantour/widgets/price_tag.dart'; +import 'package:restaurantour/widgets/review_tile.dart'; +import 'package:restaurantour/widgets/star_rating.dart'; +import 'package:restaurantour/widgets/status_tag.dart'; +import 'package:restaurantour/utils/extension_utils.dart'; + +class RestaurantDetail extends StatefulWidget { + const RestaurantDetail({Key? key}) : super(key: key); + + @override + State createState() => _RestaurantDetailState(); +} + +class _RestaurantDetailState extends State { + Restaurant? restaurant; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (ModalRoute.of(context)?.settings.arguments == null) return; + restaurant = ModalRoute.of(context)?.settings.arguments as Restaurant; + + setState(() {}); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: Icon(Icons.arrow_back, color: ColorManager.black), + onPressed: () { + Navigator.pop(context); + }, + ), + title: Text(restaurant?.name ?? AppStrings.restaurantDetail), + actions: [_favoriteButton()], + ), + body: (restaurant == null) + ? Center( + child: CircularProgressIndicator( + color: ColorManager.black, + )) + : ListView( + children: [ + Hero( + tag: 'Restaurant_${restaurant?.id}', + child: Image.network( + restaurant!.heroImage, + fit: BoxFit.cover, + width: double.infinity, + height: MediaQuery.of(context).size.height * 0.4, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: AppPadding.padding24.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: AppPadding.padding24.h), + _statusContent(), + _setDivider(), + + // Address content + _setSubtitle(AppStrings.address), + _customLabel(restaurant?.location?.formattedAddress ?? AppStrings.noData, FontSize.s12.sp), + _setDivider(), + + // rating content + _setSubtitle(AppStrings.overallRating), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _customLabel(restaurant?.rating.toString() ?? '0', FontSize.s24.sp), + const StarRating(rating: 1), + ], + ), + _setDivider(), + + // reviews content + _setSubtitle( + '${restaurant?.reviews?.length ?? 0} ${(restaurant?.reviews?.length == 1) ? AppStrings.review : AppStrings.reviews}', + ), + ...getReviewList(restaurant?.reviews), + ], + ), + ), + ], + ), + ); + } + + Widget _favoriteButton() { + final isFavorite = AppState().isFavorite(restaurant); + + return IconButton( + icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border, color: ColorManager.black), + onPressed: () { + if (restaurant == null) return; + if (isFavorite) { + AppState().removeFavorite(restaurant!); + } else { + AppState().addFavorite(restaurant!); + } + setState(() {}); + }, + ); + } + + Widget _setDivider() { + return Padding( + padding: EdgeInsets.symmetric(vertical: AppPadding.padding24.h), + child: const Divider( + thickness: 2, + height: 1, + ), + ); + } + + Widget _setSubtitle(String label) { + return Padding( + padding: EdgeInsets.only(bottom: AppPadding.padding16.h), + child: Text( + label, + style: getMediumTextStyle(color: ColorManager.black80, fontSize: FontSize.s12.sp), + ), + ); + } + + Widget _customLabel(String label, double fontSize) { + return Text( + label, + style: getBoldTextStyle(color: ColorManager.black80, fontSize: fontSize), + ); + } + + Widget _statusContent() { + final style = getMediumTextStyle(fontSize: FontSize.s12.sp, color: ColorManager.black80); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PriceTag( + price: restaurant?.price, + category: restaurant?.categories.firstOrNull?.title, + style: style, + ), + StatusTag( + isOpen: restaurant?.isOpen ?? false, + style: style, + ), + ], + ); + } + + List getReviewList(List? reviews) { + if (reviews == null) return []; + + List list = []; + for (int i = 0; i < reviews.length; i++) { + if (i != 0) list.add(_setDivider()); + + list.add(ReviewTile(review: reviews[i])); + } + return list; + } +} diff --git a/lib/screens/restaurants_screen.dart b/lib/screens/restaurants_screen.dart new file mode 100644 index 00000000..b28eb495 --- /dev/null +++ b/lib/screens/restaurants_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:restaurantour/app_state.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/repositories/yelp_repository.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; +import 'package:restaurantour/widgets/restaurant_tile.dart'; + +class RestaurantsScreen extends StatefulWidget { + const RestaurantsScreen({Key? key}) : super(key: key); + + @override + State createState() => _RestaurantsScreenState(); +} + +class _RestaurantsScreenState extends State { + List? _restaurants; + + Future> _getRestaurants() async { + final yelpRepo = YelpRepository(); + + try { + final result = await yelpRepo.getRestaurants(); + return result?.restaurants ?? []; + } catch (e) { + print('Failed to fetch restaurants: $e'); + return []; + } + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text(AppStrings.appName), + bottom: const TabBar( + tabs: [ + Tab(text: AppStrings.allRestaurants), + Tab(text: 'My Favorites'), + ], + ), + ), + body: TabBarView( + children: [ + // First tab content + _allRestaurantsTab(), + // Second tab content + _favoritesTab(), + ], + ), + ), + ); + } + + Widget _allRestaurantsTab() { + return FutureBuilder>( + future: _getRestaurants(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center(child: CircularProgressIndicator(color: ColorManager.black)); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (snapshot.hasData) { + _restaurants = snapshot.data; + + if (_restaurants?.isEmpty ?? true) { + return const Center(child: Text(AppStrings.emptyList)); + } + + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(vertical: AppPadding.padding8.h), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _restaurants?.length, + itemBuilder: (context, index) { + return RestaurantTile(restaurant: _restaurants![index]); + }, + ), + ), + ); + } else { + return const Center(child: Text(AppStrings.noData)); + } + }, + ); + } + + Widget _favoritesTab() { + final favorites = context.watch().favorites; + + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.symmetric(vertical: AppPadding.padding8.h), + child: favorites.isEmpty + ? const Center(child: Text(AppStrings.emptyList)) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: favorites.length, + itemBuilder: (context, index) { + return RestaurantTile(restaurant: favorites[index]); + }, + ), + ), + ); + } +} diff --git a/lib/utils/extension_utils.dart b/lib/utils/extension_utils.dart new file mode 100644 index 00000000..18f03047 --- /dev/null +++ b/lib/utils/extension_utils.dart @@ -0,0 +1,17 @@ +extension IterableExtension on Iterable? { + bool get isNullOrEmpty { + return this == null || this!.isEmpty; + } + + bool get isNotEmptyOrNull { + return !this.isNullOrEmpty; + } + + T? get firstOrNull { + return this.isNotEmptyOrNull ? this!.first : null; + } + + T? get lastOrNull { + return this.isNotEmptyOrNull ? this!.last : null; + } +} diff --git a/lib/utils/helper_utils.dart b/lib/utils/helper_utils.dart new file mode 100644 index 00000000..b529f04d --- /dev/null +++ b/lib/utils/helper_utils.dart @@ -0,0 +1,8 @@ +class Utils { + static int roundDoubleToInt(double? value) { + if (value == null || value <= 0) { + return 0; + } + return value.round(); + } +} diff --git a/lib/utils/mock_data.dart b/lib/utils/mock_data.dart new file mode 100644 index 00000000..3dcd0d4d --- /dev/null +++ b/lib/utils/mock_data.dart @@ -0,0 +1,165 @@ +class MockData { + static String yelpResponse = r''' +{ + "total": 6194, + "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": "PdS4Fv6RKyBQ1nB0L0wpsg", + "rating": 5, + "user": {"id": "TVnNlNYw5uFp-D-lv9REXA", "image_url": null, "name": "Chubby T."} + }, + { + "id": "9rADlcW-gfmu-F_bHK6WOw", + "rating": 3, + "user": {"id": "YKh3b-qojo4vjtHEZoIjKA", "image_url": null, "name": "Jeheon L."} + }, + { + "id": "3kACPCeDQUesPD6Dj7pHHQ", + "rating": 5, + "user": {"id": "InFweVBJ5d6dL6wVwUpmjQ", "image_url": null, "name": "Marelyn R."} + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Seafood", "alias": "seafood"} + ], + "hours": [{"is_open_now": true}], + "location": {"formatted_address": "3570 Las Vegas Blvd S Las 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": "jF_ltrsWELOE3J62CfjVOA", + "rating": 5, + "user": {"id": "L6R9AgLVcYZRex-zD2dyGQ", "image_url": null, "name": "Rodil A."} + }, + { + "id": "IN-fzeDTSemdZjOlBSW-Xw", + "rating": 5, + "user": {"id": "goYizeAZdZbQhZZE5QOe8w", "image_url": null, "name": "Cliff G."} + }, + { + "id": "x9RWVj4xZdV_oep2i6c1sA", + "rating": 5, + "user": {"id": "PY9912npDSkcfO3He7bosQ", "image_url": null, "name": "Hiyori G."} + } + ], + "categories": [ + {"title": "Southern", "alias": "southern"}, + {"title": "New American", "alias": "newamerican"}, + {"title": "Cocktail Bars", "alias": "cocktailbars"} + ], + "hours": [{"is_open_now": true}], + "location": {"formatted_address": "3355 Las Vegas Blvd S Las 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": "sZVa1-2TWjgJEnKGJYYB4Q", + "rating": 5, + "user": {"id": "Poe6Ka98uk2V3FTH25gmVQ", "image_url": null, "name": "Cynthia D."} + }, + { + "id": "zgilgF7G_DvrzAh6rbqrZg", + "rating": 5, + "user": {"id": "JWA1wHHdgZa1O0_Atk5Xrg", "image_url": null, "name": "Vivian Y."} + }, + { + "id": "rGjBuZgMX-DYchGuKJHZ0A", + "rating": 5, + "user": {"id": "wD5hDv81oNYyMQd0XitowA", "image_url": null, "name": "Alan P."} + } + ], + "categories": [ + {"title": "American (New)", "alias": "newamerican"}, + {"title": "Tapas/Small Plates", "alias": "tapasmallplates"}, + {"title": "Bars", "alias": "bars"} + ], + "hours": [{"is_open_now": true}], + "location": {"formatted_address": "124 S 6th St Las Vegas, NV 89101"} + }, + { + "id": "UidEFF1WpnU4duev4fjPlQ", + "name": "Therapy", + "price": "$$", + "rating": 4.3, + "photos": ["https://s3-media3.fl.yelpcdn.com/bphoto/otaMuPtauoEb6qZzmHlAlQ/o.jpg"], + "reviews": [ + { + "id": "PsR_yQOXt_w8PUkTGlBjkA", + "rating": 5, + "user": {"id": "VmSDPCypfNRYJL6iMXqQZQ", "image_url": null, "name": "Zoe C."} + }, + { + "id": "t5KE0YZKeRGxX8TLl17SVw", + "rating": 5, + "user": {"id": "xhC7iVSHkf9pdXu2NVDAhA", "image_url": null, "name": "Grant W."} + }, + { + "id": "49zIJLuJkZRj460MfGAj6A", + "rating": 5, + "user": {"id": "Qb_YdQd6IdogNBzCnSu5bw", "image_url": "https://s3-media1.fl.yelpcdn.com/photo/SyE0UJtWlVo9I1BKZbzrfA/o.jpg", "name": "Jessamyn C."} + } + ], + "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 Las Vegas, NV 89101"} + }, + { + "id": "mU3vlAVzTxgmZUu6F4XixA", + "name": "Momofuku", + "price": "$$", + "rating": 4.1, + "photos": ["https://s3-media1.fl.yelpcdn.com/bphoto/mB1g53Nqa62Q04u4oNuCSw/o.jpg"], + "reviews": [ + { + "id": "35EBt6dRrX4Qug_xdZh0gQ", + "rating": 4, + "user": {"id": "k_2KtwnTyFwCp3jolV7VMQ", "image_url": null, "name": "jamesmarissa p."} + }, + { + "id": "DlbQl5FMwXKcaiA-M8PAFA", + "rating": 5, + "user": {"id": "KKijecFeQInjpAQuoLQbDg", "image_url": null, "name": "Stephanie A."} + }, + { + "id": "tn-MGVazgja5OsgVBnLmEQ", + "rating": 5, + "user": {"id": "R9TOJHFqYTExNMtEdbhvRQ", "image_url": "https://s3-media1.fl.yelpcdn.com/photo/m8e_G1oOUF11HjHc91gdoQ/o.jpg", "name": "Bob J."} + } + ], + "categories": [ + {"title": "New American", "alias": "newamerican"}, + {"title": "Asian Fusion", "alias": "asianfusion"} + ], + "hours": [{"is_open_now": false}], + "location": {"formatted_address": "2 Boulevard Tower Level Boulevard Tower Las Vegas, NV 89109"} + } + ] +} +'''; + + static String loremReview = + "Eiusmod ea ullamco enim est ex dolor non et exercitation incididunt ex magna deserunt amet. Nisi amet dolore occaecat cillum anim eu in in mollit occaecat sunt. Culpa id aliquip do dolore. Ipsum fugiat est enim dolor dolore in consequat commodo."; +} diff --git a/lib/widgets/price_tag.dart b/lib/widgets/price_tag.dart new file mode 100644 index 00000000..5c436234 --- /dev/null +++ b/lib/widgets/price_tag.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; + +class PriceTag extends StatelessWidget { + final String? price; + final String? category; + final TextStyle? style; + const PriceTag({ + Key? key, + this.price, + this.category, + this.style, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox(width: AppSize.size4.w), + Text(price ?? '', style: style), + SizedBox(width: AppSize.size4.w), + Text(category ?? '', style: style), + ], + ); + } +} diff --git a/lib/widgets/restaurant_tile.dart b/lib/widgets/restaurant_tile.dart new file mode 100644 index 00000000..f4c91555 --- /dev/null +++ b/lib/widgets/restaurant_tile.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; +import 'package:restaurantour/resources/routes_manager.dart'; +import 'package:restaurantour/utils/extension_utils.dart'; +import 'package:restaurantour/widgets/price_tag.dart'; +import 'package:restaurantour/widgets/star_rating.dart'; +import 'package:restaurantour/widgets/status_tag.dart'; + +class RestaurantTile extends StatelessWidget { + final Restaurant restaurant; + const RestaurantTile({ + Key? key, + required this.restaurant, + }) : super(key: key); + + void _goToPage(BuildContext context) { + Navigator.pushNamed( + context, + Routes.restaurantDetailRoute, + arguments: restaurant, + ); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + margin: const EdgeInsets.symmetric( + horizontal: AppSize.size16, + vertical: AppSize.size4, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () => _goToPage(context), + child: Row( + children: [ + Expanded( + child: Hero( + tag: 'Restaurant_${restaurant.id}', + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSize.size8), + child: Image.network( + restaurant.heroImage, + width: AppSize.size80.sp, + height: AppSize.size80.sp, + fit: BoxFit.cover, + ), + ), + ), + ), + SizedBox(width: AppPadding.padding16.w), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + restaurant.name ?? AppStrings.noData, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: getSemiBoldTextStyle(color: ColorManager.black80, fontSize: FontSize.s16.sp), + ), + SizedBox(height: AppPadding.padding8.h), + PriceTag(price: restaurant.price, category: restaurant.categories.firstOrNull?.title), + SizedBox(height: AppSize.size4.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StarRating(rating: restaurant.rating), + StatusTag(isOpen: restaurant.isOpen), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/review_tile.dart b/lib/widgets/review_tile.dart new file mode 100644 index 00000000..f3229333 --- /dev/null +++ b/lib/widgets/review_tile.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; +import 'package:restaurantour/utils/mock_data.dart'; +import 'package:restaurantour/widgets/star_rating.dart'; + +class ReviewTile extends StatelessWidget { + final Review review; + + const ReviewTile({ + Key? key, + required this.review, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StarRating(rating: review.rating?.toDouble(), size: AppSize.size16), + SizedBox(height: AppPadding.padding4.h), + Text(MockData.loremReview), + SizedBox(height: AppPadding.padding8.h), + Row( + children: [ + if (review.user?.imageUrl != null) + CircleAvatar( + backgroundImage: NetworkImage(review.user?.imageUrl ?? ''), + ) + else + CircleAvatar( + backgroundColor: ColorManager.blue, + child: Text( + review.user?.name?.substring(0, 1) ?? '', + style: TextStyle(fontSize: FontSize.s24.sp, color: ColorManager.white), + ), + ), + SizedBox(width: AppPadding.padding8.h), + Text(review.user?.name ?? AppStrings.noData), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/star_rating.dart b/lib/widgets/star_rating.dart new file mode 100644 index 00000000..59da800f --- /dev/null +++ b/lib/widgets/star_rating.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/resources/color_manager.dart'; +import 'package:restaurantour/utils/helper_utils.dart'; + +class StarRating extends StatelessWidget { + final double? rating; + final double? size; + const StarRating({ + Key? key, + this.rating, + this.size, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final starCount = Utils.roundDoubleToInt(rating); + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + starCount, + (index) => Icon( + Icons.star, + color: ColorManager.yellow, + size: size, + ), + ), + ); + } +} diff --git a/lib/widgets/status_tag.dart b/lib/widgets/status_tag.dart new file mode 100644 index 00000000..6aafbfc9 --- /dev/null +++ b/lib/widgets/status_tag.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:restaurantour/resources/resources_exports.dart'; + +class StatusTag extends StatelessWidget { + final bool isOpen; + final TextStyle? style; + const StatusTag({Key? key, this.isOpen = false, this.style}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isOpen ? AppStrings.isOpen : AppStrings.isClosed, style: style), + SizedBox(width: AppSize.size4.w), + Icon( + Icons.circle, + size: AppSize.size8, + color: isOpen ? ColorManager.success : ColorManager.error, + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0b052c68..3f747774 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ 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: @@ -222,6 +222,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: "9357883bdd153ab78cbf9ffa07656e336b8bbb2b5a3ca596b0b27e119f7c7d77" + url: "https://pub.dev" + source: hosted + version: "5.1.0" flutter_lints: dependency: "direct dev" description: @@ -230,6 +238,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8cf100b8e4973dc570b6415a2090b0bfaa8756ad333db46939efc3e774ee100d" + url: "https://pub.dev" + source: hosted + version: "5.9.0" flutter_svg: dependency: "direct main" description: @@ -351,10 +367,10 @@ 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: @@ -363,6 +379,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -391,10 +415,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "5.4.0" pool: dependency: transitive description: @@ -403,6 +427,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + url: "https://pub.dev" + source: hosted + version: "6.1.1" pub_semver: dependency: transitive description: @@ -468,18 +500,18 @@ 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: @@ -508,10 +540,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" timing: dependency: transitive description: @@ -572,10 +604,10 @@ packages: 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: @@ -588,10 +620,10 @@ packages: 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: @@ -601,5 +633,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.7.0-0" + dart: ">=3.1.0-185.0.dev <3.19.10" + flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index be3055e0..d0a75a31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,12 @@ 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" + sdk: ">=2.12.0 <3.19.10" dependencies: flutter: @@ -17,6 +16,15 @@ dependencies: json_annotation: ^4.8.1 flutter_svg: ^2.0.9 + #responsive text + flutter_screenutil: ^5.5.3+2 + + #env variables + flutter_dotenv: ^5.1.0 + + #state management + provider: ^6.1.1 + dev_dependencies: flutter_test: sdk: flutter @@ -26,5 +34,5 @@ dev_dependencies: flutter: uses-material-design: true -# assets: -# - assets/svg/ \ No newline at end of file + assets: + - .env diff --git a/test/lib/app_state_test.dart b/test/lib/app_state_test.dart new file mode 100644 index 00000000..6ee45a79 --- /dev/null +++ b/test/lib/app_state_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/models/restaurant.dart'; +import 'package:restaurantour/app_state.dart'; + +void main() { + group('AppState', () { + late AppState appState; + + setUp(() { + appState = AppState(); + }); + + test('favorites should be initially empty', () { + expect(appState.favorites, isEmpty); + }); + + test('addFavorite should add a restaurant to favorites', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant A'); + appState.addFavorite(restaurant); + expect(appState.favorites, contains(restaurant)); + }); + + test('addFavorite should not add duplicate restaurants to favorites', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant A'); + appState.addFavorite(restaurant); + appState.addFavorite(restaurant); + expect(appState.favorites.length, 1); + }); + + test('removeFavorite should remove a restaurant from favorites', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant A'); + appState.addFavorite(restaurant); + appState.removeFavorite(restaurant); + expect(appState.favorites, isNot(contains(restaurant))); + }); + + test('isFavorite should return true for a restaurant in favorites', () { + const restaurant = Restaurant(id: '1', name: 'Restaurant A'); + appState.addFavorite(restaurant); + expect(appState.isFavorite(restaurant), isTrue); + }); + + test('isFavorite should return false for a restaurant not in favorites', () { + const restaurant = Restaurant(id: '2', name: 'Restaurant B'); + expect(appState.isFavorite(restaurant), isFalse); + }); + }); +} diff --git a/test/lib/widgets/star_rating_test.dart b/test/lib/widgets/star_rating_test.dart new file mode 100644 index 00000000..c9ef6129 --- /dev/null +++ b/test/lib/widgets/star_rating_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:restaurantour/widgets/star_rating.dart'; + +void main() { + testWidgets('StarRating displays correct number of stars', (WidgetTester tester) async { + // Create a test widget + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StarRating( + rating: 3.8, + size: 24.0, + ), + ), + ), + ); + + // Verify that the StarRating widget displays the correct number of stars + expect(find.byIcon(Icons.star), findsNWidgets(4)); + }); +} 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); - }); -}