From fe26838eb4c079fbd287fc4153af4c7fec74dfcf Mon Sep 17 00:00:00 2001 From: Sunder Kumar Date: Fri, 24 Feb 2023 10:13:19 +0530 Subject: [PATCH] added error handling and error state UI --- lib/helpers/http_exception.dart | 17 ++ lib/helpers/http_helper.dart | 57 ++++-- lib/screens/details_screen.dart | 271 ++++++++++++++++----------- lib/screens/search_screen.dart | 241 ++++++++++++++---------- lib/screens/video_player_screen.dart | 28 +-- lib/widgets/row_sliver.dart | 116 +++++++++--- pubspec.yaml | 2 +- 7 files changed, 464 insertions(+), 268 deletions(-) create mode 100644 lib/helpers/http_exception.dart diff --git a/lib/helpers/http_exception.dart b/lib/helpers/http_exception.dart new file mode 100644 index 0000000..99e33df --- /dev/null +++ b/lib/helpers/http_exception.dart @@ -0,0 +1,17 @@ +class ApiException implements Exception { + final String message; + final String error; + ApiException({ + required this.message, + required this.error, + }); + + @override + String toString() { + return message; + } + + String get getBaseError { + return error; + } +} diff --git a/lib/helpers/http_helper.dart b/lib/helpers/http_helper.dart index d856552..994be4b 100644 --- a/lib/helpers/http_helper.dart +++ b/lib/helpers/http_helper.dart @@ -1,6 +1,7 @@ // ignore_for_file: constant_identifier_names import 'dart:convert'; +import 'package:anime_api/helpers/http_exception.dart'; import 'package:http/http.dart' as http; enum GetLanding { @@ -12,21 +13,34 @@ enum GetLanding { class HttpHelper { static const baseUrl = "anime-api-vnkr.onrender.com"; - //broken static Future> searchApi({ required String query, }) async { final url = Uri.https(baseUrl, "/search/$query"); - final response = await http.get(url); - return json.decode(response.body) as Map; + try { + final response = await http.get(url); + return json.decode(response.body) as Map; + } catch (err) { + throw ApiException( + error: err.toString(), + message: "Failed to search.", + ); + } } static Future> getInfo({ required int malID, }) async { final url = Uri.https(baseUrl, "/info/$malID"); - final response = await http.get(url); - return json.decode(response.body) as Map; + try { + final response = await http.get(url); + return json.decode(response.body) as Map; + } catch (err) { + throw ApiException( + error: err.toString(), + message: "Failed to load info.", + ); + } } static Future> getVideoSources({ @@ -34,8 +48,15 @@ class HttpHelper { required String animeId, }) async { final url = Uri.https(baseUrl, "/watch/$animeId/$episodeID"); - final response = await http.get(url); - return json.decode(response.body) as List; + try { + final response = await http.get(url); + return json.decode(response.body) as List; + } catch (err) { + throw ApiException( + error: err.toString(), + message: "Failed to load streaming info.", + ); + } } static Future> getEpisodeList({ @@ -44,15 +65,29 @@ class HttpHelper { String? season = "unknown", }) async { final url = Uri.https(baseUrl, "$title/$releasedYear/$season"); - final response = await http.get(url); - return json.decode(response.body) as Map; + try { + final response = await http.get(url); + return json.decode(response.body) as Map; + } catch (err) { + throw ApiException( + error: err.toString(), + message: "Failed to load episode info.", + ); + } } static Future> getLanding({ required GetLanding landing, }) async { final url = Uri.https(baseUrl, landing.name); - final response = await http.get(url); - return json.decode(response.body); + try { + final response = await http.get(url); + return json.decode(response.body); + } catch (err) { + throw ApiException( + error: err.toString(), + message: "Looks like there was a network failure.", + ); + } } } diff --git a/lib/screens/details_screen.dart b/lib/screens/details_screen.dart index f40cfc5..65591c4 100644 --- a/lib/screens/details_screen.dart +++ b/lib/screens/details_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:html/parser.dart'; import 'package:provider/provider.dart'; +import '../constants/app_colors.dart'; import '../helpers/custom_route.dart'; import '../helpers/http_helper.dart'; import '../screens/video_player_screen.dart'; @@ -25,6 +26,9 @@ class DetailsScreen extends StatefulWidget { class _DetailsScreenState extends State with TickerProviderStateMixin { Map? fetchedData; + bool hasError = false; + String? errorMessage; + late final AnimationController _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 500)) ..forward(); @@ -33,6 +37,28 @@ class _DetailsScreenState extends State curve: Curves.easeInCubic, ); + void getData() async { + try { + setState(() { + hasError = false; + }); + final result = await HttpHelper.getInfo( + malID: (int.parse( + (ModalRoute.of(context)?.settings.arguments + as Map)["id"], + )), + ); + setState(() { + fetchedData = result; + }); + } catch (err) { + setState(() { + hasError = true; + errorMessage = err.toString(); + }); + } + } + @override void dispose() { _animationController.dispose(); @@ -41,20 +67,7 @@ class _DetailsScreenState extends State @override void didChangeDependencies() { - HttpHelper.getInfo( - malID: (int.parse( - (ModalRoute.of(context)?.settings.arguments - as Map)["id"], - )), - ).then( - (value) { - setState(() { - fetchedData = value; - }); - return value; - }, - ); - + getData(); super.didChangeDependencies(); } @@ -147,115 +160,145 @@ class _DetailsScreenState extends State ), ), ), - SliverToBoxAdapter( - child: Flex( - direction: Axis.vertical, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox( - height: 30, - ), - if (fetchedData == null) - Center( - child: SpinKitFoldingCube( - color: Theme.of(context).colorScheme.primary, - size: 50, - ), + hasError + ? SliverToBoxAdapter( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + errorMessage!, + style: Theme.of(context).textTheme.displayLarge, + textAlign: TextAlign.center, + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightblack, + foregroundColor: AppColors.green, + minimumSize: const Size(150, 45), + ), + onPressed: getData, + child: const Text("Refresh"), + ), + ], ), - if (fetchedData != null) ...[ - ElevatedButton( - onPressed: fetchedData!["episodes"].length == 0 - ? null - : () { - final data = fetchedData!["episodes"][0]; - Navigator.of(context).push( - CustomRoute( - builder: (context) { - return VideoPlayerScreen( - season: fetchedData!["season"] - ?.toString() - .trim() - .toLowerCase(), - releasedYear: fetchedData!["releaseDate"], - title: fetchedData!["title"], - gogoDetails: fetchedData!["episodes"], - episode: index != -1 - ? history[index]["episode"] - : data["number"], - image: fetchedData!["image"], - id: fetchedData!["id"], - position: index != -1 - ? history[index]["position"] - : 0, + ) + : SliverToBoxAdapter( + child: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox( + height: 30, + ), + if (fetchedData == null) + Center( + child: SpinKitFoldingCube( + color: Theme.of(context).colorScheme.primary, + size: 50, + ), + ), + if (fetchedData != null) ...[ + ElevatedButton( + onPressed: fetchedData!["episodes"].length == 0 + ? null + : () { + final data = fetchedData!["episodes"][0]; + Navigator.of(context).push( + CustomRoute( + builder: (context) { + return VideoPlayerScreen( + season: fetchedData!["season"] + ?.toString() + .trim() + .toLowerCase(), + releasedYear: + fetchedData!["releaseDate"], + title: fetchedData!["title"], + gogoDetails: fetchedData!["episodes"], + episode: index != -1 + ? history[index]["episode"] + : data["number"], + image: fetchedData!["image"], + id: fetchedData!["id"], + position: index != -1 + ? history[index]["position"] + : 0, + ); + }, + ), ); }, - ), - ); - }, - child: Text( - index != -1 - ? "Continue Watching \u2022 E${history[index]["episode"]}" - : "Start Watching", - ), - ), - const SizedBox( - height: 30, - ), - if (fetchedData!["description"] != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: RichText( - maxLines: 15, - overflow: TextOverflow.ellipsis, - text: TextSpan( - text: "Overview: ", - style: - Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.amber, - fontWeight: FontWeight.w900, - fontSize: 13, + child: Text( + index != -1 + ? "Continue Watching \u2022 E${history[index]["episode"]}" + : "Start Watching", + ), + ), + const SizedBox( + height: 30, + ), + if (fetchedData!["description"] != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: RichText( + maxLines: 15, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: "Overview: ", + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Colors.amber, + fontWeight: FontWeight.w900, + fontSize: 13, + ), + children: [ + TextSpan( + text: parse(fetchedData!["description"]) + .body + ?.text as String, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Colors.grey, + fontSize: 13, + ), ), - children: [ - TextSpan( - text: parse(fetchedData!["description"]) - .body - ?.text as String, + ], + ), + ), + ), + const SizedBox( + height: 10, + ), + if (fetchedData!["totalEpisodes"] > 0) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 10, + ), + child: Text( + "Episodes", style: Theme.of(context) .textTheme - .titleMedium + .displayLarge ?.copyWith( - color: Colors.grey, - fontSize: 13, + fontSize: 24, ), ), - ], - ), - ), - ), - const SizedBox( - height: 10, + ), + ], + ], ), - if (fetchedData!["totalEpisodes"] > 0) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 10, - ), - child: Text( - "Episodes", - style: - Theme.of(context).textTheme.displayLarge?.copyWith( - fontSize: 24, - ), - ), - ), - ], - ], - ), - ), + ), if (fetchedData != null) SliverList( delegate: SliverChildBuilderDelegate( @@ -455,7 +498,7 @@ class _DetailsScreenState extends State ), ), ] - ] + ], ], ), ); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 8343084..f29a88c 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'package:provider/provider.dart'; +import '../constants/app_colors.dart'; import '../helpers/http_helper.dart'; import '../widgets/preferences_modal.dart'; import '../widgets/search_card.dart'; @@ -22,18 +23,28 @@ class _SearchScreenState extends State { TextEditingController? _controller; final FocusNode _focusNode = FocusNode(); Map? fetchedData; + bool hasError = false; + String? errorMessage; void fetchData(String query) async { - setState(() { - isLoading = true; - }); - final response = await HttpHelper.searchApi( - query: _controller!.text, - ); - setState(() { - isLoading = false; - fetchedData = response; - }); + try { + setState(() { + hasError = false; + isLoading = true; + }); + final response = await HttpHelper.searchApi( + query: _controller!.text, + ); + setState(() { + isLoading = false; + fetchedData = response; + }); + } catch (err) { + setState(() { + hasError = true; + errorMessage = err.toString(); + }); + } } @override @@ -76,100 +87,134 @@ class _SearchScreenState extends State { body: Flex( direction: Axis.vertical, children: [ - if (fetchedData == null) ...[ - isLoading - ? Flexible( - child: Center( - child: SpinKitThreeInOut( - color: Theme.of(context).colorScheme.primary, - size: 30, + hasError + ? Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + errorMessage!, + style: Theme.of(context).textTheme.displayLarge, + textAlign: TextAlign.center, + ), ), - ), - ) - : FutureBuilder( - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Flexible( - child: SpinKitThreeBounce( - color: Theme.of(context).colorScheme.primary, - size: 20, - ), - ); - } - return Flexible( - child: ListView.builder( - reverse: true, - itemBuilder: (context, index) { - final data = snapshot.data!.reversed.toList(); - return ListTile( - dense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - ), - leading: const Icon(Icons.history), - title: Text( - data[index].toString(), - style: Theme.of( - context, - ).textTheme.displayLarge, + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightblack, + foregroundColor: AppColors.green, + minimumSize: const Size(150, 45), + ), + onPressed: () => fetchData(_controller!.text), + child: const Text("Refresh"), + ), + ], + ), + ) + : isLoading + ? Flexible( + child: Center( + child: SpinKitThreeInOut( + color: Theme.of(context).colorScheme.primary, + size: 30, + ), + ), + ) + : fetchedData == null + ? FutureBuilder( + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Flexible( + child: SpinKitThreeBounce( + color: + Theme.of(context).colorScheme.primary, + size: 20, + ), + ); + } + return Flexible( + child: ListView.builder( + reverse: true, + itemBuilder: (context, index) { + final data = + snapshot.data!.reversed.toList(); + return ListTile( + dense: true, + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + ), + leading: const Icon(Icons.history), + title: Text( + data[index].toString(), + style: Theme.of( + context, + ).textTheme.displayLarge, + ), + onTap: () { + _controller?.text = + data[index].toString(); + _focusNode.requestFocus(); + }, + ); + }, + itemCount: snapshot.data?.length, ), - onTap: () { - _controller?.text = data[index].toString(); - _focusNode.requestFocus(); - }, ); }, - itemCount: snapshot.data?.length, - ), - ); - }, - future: Provider.of( - context, - listen: false, - ).fetchSearchHistory(), - ), - ], - if (fetchedData != null && isLoading == false) - Flexible( - child: CustomScrollView( - slivers: [ - if (fetchedData!["results"].length == 0) - SliverToBoxAdapter( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.8, - child: Center( - child: Text( - "Sorry, nothing found!", - style: Theme.of(context).textTheme.displayLarge, + future: Provider.of( + context, + listen: false, + ).fetchSearchHistory(), + ) + : Flexible( + child: CustomScrollView( + slivers: [ + if (fetchedData!["results"].length == 0) + SliverToBoxAdapter( + child: SizedBox( + height: + MediaQuery.of(context).size.height * + 0.8, + child: Center( + child: Text( + "Sorry, nothing found!", + style: Theme.of(context) + .textTheme + .displayLarge, + ), + ), + ), + ), + SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 250, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final data = + fetchedData!["results"][index]; + return SearchCard( + callback: () => + FocusScope.of(context).unfocus(), + title: data["title"], + type: data["type"], + image: data["image"], + id: data["id"], + disabled: + data["status"] == "Not yet aired" || + data["malId"] == null, + ); + }, + childCount: fetchedData!["results"].length, + ), + ), + ], ), ), - ), - ), - SliverGrid( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisExtent: 250, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final data = fetchedData!["results"][index]; - return SearchCard( - callback: () => FocusScope.of(context).unfocus(), - title: data["title"], - type: data["type"], - image: data["image"], - id: data["id"], - disabled: data["status"] == "Not yet aired" || - data["malId"] == null, - ); - }, - childCount: fetchedData!["results"].length, - ), - ), - ], - ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10), child: TextField( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fb29595..f052dba 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -47,24 +47,24 @@ class _VideoPlayerScreenState extends State { bool hasError = false; Future fetchEpisodeList() async { - final result = await HttpHelper.getEpisodeList( - title: widget.title["romaji"] ?? "", - releasedYear: widget.releasedYear, - season: widget.season, - ); - if (result["error"] != null) { + try { + final result = await HttpHelper.getEpisodeList( + title: widget.title["romaji"] ?? "", + releasedYear: widget.releasedYear, + season: widget.season, + ); + setState(() { + animepaheData = result; + }); + getEpisode( + episode: currentEpisode!, + position: widget.position, + ); + } catch (err) { setState(() { hasError = true; }); - return; } - setState(() { - animepaheData = result; - }); - getEpisode( - episode: currentEpisode!, - position: widget.position, - ); } Future getEpisode({ diff --git a/lib/widgets/row_sliver.dart b/lib/widgets/row_sliver.dart index 6608eee..e26f5ab 100644 --- a/lib/widgets/row_sliver.dart +++ b/lib/widgets/row_sliver.dart @@ -1,47 +1,103 @@ +import 'package:anime_api/constants/app_colors.dart'; + import '../widgets/row_item.dart'; import 'package:flutter/material.dart'; import '../helpers/http_helper.dart'; -class RowSliver extends StatelessWidget { +class RowSliver extends StatefulWidget { final GetLanding option; const RowSliver({super.key, required this.option}); + @override + State createState() => _RowSliverState(); +} + +class _RowSliverState extends State { + List? fetchedData; + bool hasError = false; + String? errorMessage; + + void getData() async { + try { + setState(() { + hasError = false; + }); + await Future.delayed(const Duration(milliseconds: 500)); + final result = await HttpHelper.getLanding(landing: widget.option); + setState(() { + fetchedData = result["results"]; + }); + } catch (err) { + setState(() { + hasError = true; + errorMessage = err.toString(); + }); + } + } + + @override + void initState() { + getData(); + super.initState(); + } + @override Widget build(BuildContext context) { - return FutureBuilder( - builder: (context, snapshot) { - if (!snapshot.hasData) { - return SliverToBoxAdapter( + return hasError + ? SliverToBoxAdapter( child: SizedBox( height: MediaQuery.of(context).size.height * 0.7, - child: const Center( - child: CircularProgressIndicator( - color: Colors.white, - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + errorMessage!, + style: Theme.of(context).textTheme.displayLarge, + textAlign: TextAlign.center, + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightblack, + foregroundColor: AppColors.green, + minimumSize: const Size(150, 45), + ), + onPressed: getData, + child: const Text("Refresh"), + ), + ], ), ), - ); - } - final fetchedData = snapshot.data!["results"]; - return SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisExtent: 280, - ), - itemBuilder: (context, index) => RowItem( - id: fetchedData[index]["id"], - image: fetchedData[index]["image"], - title: fetchedData[index]["title"], - tag: option.name + - fetchedData[index]["id"].toString() + - fetchedData[index]["episodeId"].toString(), - ), - itemCount: fetchedData.length, - ); - }, - future: HttpHelper.getLanding(landing: option), - ); + ) + : fetchedData == null + ? SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ), + ) + : SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 280, + ), + itemBuilder: (context, index) => RowItem( + id: fetchedData![index]["id"], + image: fetchedData![index]["image"], + title: fetchedData![index]["title"], + tag: widget.option.name + + fetchedData![index]["id"].toString() + + fetchedData![index]["episodeId"].toString(), + ), + itemCount: fetchedData!.length, + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 40679d6..bb3d645 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 3.1.2+1 +version: 3.2.0+1 environment: sdk: ">=2.18.6 <3.0.0"