diff --git a/android/gradle.properties b/android/gradle.properties index 7aef769d163302288fe58ec83e6b4f3d21afd030..8f1cf62360192199ae6a5e17a062114475c411bc 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -app.versionName=1.0.36 -app.versionCode=37 +app.versionName=1.0.37 +app.versionCode=38 diff --git a/assets/translations/en.json b/assets/translations/en.json index d90a462d962ae53f8ef610e33ab4bc347b0a5909..e57ee8a1085d69a27cfce73eaa027ae8bdb146ab 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -2,10 +2,13 @@ "app_name": "Sandbox App", "bottom_nav_sample": "Sample", + "bottom_nav_api": "API", "bottom_nav_chart": "Graph", "bottom_nav_settings": "Settings", "bottom_nav_about": "About", + "api_page_title": "API", + "settings_title": "Settings", "settings_label_api_url": "API URL:", "settings_label_security_token": "Security token:", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 67575f68dbb45583075ae61204c3a66c79a1fba2..ef1e45bbdd2bdd2d787f6838a3ee09a145086988 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -2,10 +2,13 @@ "app_name": "App de test", "bottom_nav_sample": "Démo", + "bottom_nav_api": "API", "bottom_nav_chart": "Graph", "bottom_nav_settings": "Paramètres", "bottom_nav_about": "À propos", + "api_page_title": "API", + "settings_title": "Paramètres", "settings_label_api_url": "URL de l'API :", "settings_label_security_token": "Jeton de sécurité :", diff --git a/lib/cubit/api_cubit.dart b/lib/cubit/api_cubit.dart new file mode 100644 index 0000000000000000000000000000000000000000..27b9f29f23600566ae274aaf16bc9b5a002e319f --- /dev/null +++ b/lib/cubit/api_cubit.dart @@ -0,0 +1,62 @@ +import 'package:equatable/equatable.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import 'package:random/models/api_failure.dart'; +import 'package:random/models/api_data.dart'; +import 'package:random/repository/api.dart'; + +part 'api_state.dart'; + +class ApiDataCubit extends HydratedCubit<ApiDataState> { + final ApiRepository apiRepository; + + ApiDataCubit({required this.apiRepository}) : super(ApiDataFetchInitial()); + + void customEmit(ApiDataState state) { + String stateAsString = ''; + if (state is ApiDataFetchLoaded) { + stateAsString = 'ApiDataFetchLoaded'; + } else { + if (state is ApiDataFetchLoading) { + stateAsString = 'ApiDataFetchLoading'; + } else { + if (state is ApiDataFetchError) { + stateAsString = 'ApiDataFetchError'; + } else { + stateAsString = 'unknown'; + } + } + } + print('emit new state: ' + stateAsString); + + emit(state); + } + + Future<ApiData> fetchApiData() async { + customEmit(ApiDataFetchLoading()); + try { + final ApiData apiData = await apiRepository.getApiData(); + customEmit(ApiDataFetchLoaded(data: apiData)); + return apiData; + } on ApiFailure catch (err) { + customEmit(ApiDataFetchError(failure: err)); + } catch (err) { + print("Error (fetchApiData): $err"); + } + return ApiData.fromJson({}); + } + + @override + ApiDataState? fromJson(Map<String, dynamic> json) { + return ApiDataState( + data: ApiData.fromJson(json['api-data']), + ); + } + + @override + Map<String, Object?>? toJson(ApiDataState state) { + return <String, Object?>{ + 'api-data': state.data?.toJson(), + }; + } +} diff --git a/lib/cubit/api_state.dart b/lib/cubit/api_state.dart new file mode 100644 index 0000000000000000000000000000000000000000..a930be04aa0c3a19f072e8155bbba47cc2363107 --- /dev/null +++ b/lib/cubit/api_state.dart @@ -0,0 +1,38 @@ +part of 'api_cubit.dart'; + +class ApiDataState extends Equatable { + final ApiData? data; + + const ApiDataState({ + this.data, + }); + + @override + List<Object> get props => []; +} + +class ApiDataFetchInitial extends ApiDataState {} + +class ApiDataFetchLoading extends ApiDataState {} + +class ApiDataFetchLoaded extends ApiDataState { + final ApiData data; + + const ApiDataFetchLoaded({ + required this.data, + }); + + @override + List<Object> get props => [data]; +} + +class ApiDataFetchError extends ApiDataState { + final ApiFailure failure; + + const ApiDataFetchError({ + required this.failure, + }); + + @override + List<Object> get props => [failure]; +} diff --git a/lib/cubit/bottom_nav_cubit.dart b/lib/cubit/bottom_nav_cubit.dart index 177572b02aa306afe9bd9c25242001e02ba684b1..98ff898e6e0e4bc0e5f7784b40462ccb1cf1e8d3 100644 --- a/lib/cubit/bottom_nav_cubit.dart +++ b/lib/cubit/bottom_nav_cubit.dart @@ -3,7 +3,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; class BottomNavCubit extends HydratedCubit<int> { BottomNavCubit() : super(0); - int pagesCount = 4; + int pagesCount = 5; void updateIndex(int index) { if (isIndexAllowed(index)) { @@ -20,8 +20,8 @@ class BottomNavCubit extends HydratedCubit<int> { void goToHomePage() => emit(0); @override - int? fromJson(Map<String, dynamic> json) { - return json['pageIndex'] as int?; + int fromJson(Map<String, dynamic> json) { + return 0; } @override diff --git a/lib/main.dart b/lib/main.dart index 1a3b173e360e25bcfe6b0e4d852092df8cfe0154..765b84c7b8b3c00e7e4489d11ab5d77670453128 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,9 @@ import 'package:random/config/theme.dart'; import 'package:random/cubit/bottom_nav_cubit.dart'; import 'package:random/cubit/data_cubit.dart'; import 'package:random/cubit/settings_cubit.dart'; +import 'package:random/cubit/api_cubit.dart'; +import 'package:random/repository/api.dart'; +import 'package:random/network/api.dart'; import 'package:random/ui/skeleton.dart'; void main() async { @@ -47,6 +50,13 @@ class MyApp extends StatelessWidget { BlocProvider<SettingsCubit>(create: (context) => SettingsCubit()), BlocProvider<BottomNavCubit>(create: (context) => BottomNavCubit()), BlocProvider<DataCubit>(create: (context) => DataCubit()), + BlocProvider<ApiDataCubit>( + create: (context) => ApiDataCubit( + apiRepository: ApiRepository( + apiService: ApiService(), + ), + )..fetchApiData(), + ), ], child: MaterialApp( title: 'Random application', diff --git a/lib/models/api_data.dart b/lib/models/api_data.dart new file mode 100644 index 0000000000000000000000000000000000000000..8124108688a3ea77eaa5167d6dae2f52938730ca --- /dev/null +++ b/lib/models/api_data.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +class ApiData { + final int? number; + final String? md5; + final DateTime? updatedAt; + + const ApiData({ + required this.number, + required this.md5, + required this.updatedAt, + }); + + factory ApiData.fromJson(Map<String, dynamic>? json) { + return ApiData( + number: (json?['number'] != null) ? (json?['number'] as int) : null, + md5: (json?['md5'] != null) ? (json?['md5'] as String) : null, + updatedAt: (json?['now'] != null) ? DateTime.parse(json?['now']) : null, + ); + } + + Map<String, dynamic>? toJson() { + return <String, dynamic>{ + 'number': this.number ?? 0, + 'md5': this.md5 ?? '', + 'updatedAt': this.updatedAt?.toString(), + }; + } + + String toString() { + return jsonEncode(this.toJson()); + } +} diff --git a/lib/models/api_failure.dart b/lib/models/api_failure.dart new file mode 100644 index 0000000000000000000000000000000000000000..3ee6a8079c991b0cb159d52a1d03b278fcdf9d39 --- /dev/null +++ b/lib/models/api_failure.dart @@ -0,0 +1,9 @@ +class ApiFailure { + final String message; + final String code; + + const ApiFailure({ + this.message = '', + this.code = '', + }); +} diff --git a/lib/models/api_status.dart b/lib/models/api_status.dart new file mode 100644 index 0000000000000000000000000000000000000000..04933cac42c5687e38d10134abdf8a1ee1eb5080 --- /dev/null +++ b/lib/models/api_status.dart @@ -0,0 +1,5 @@ +enum ApiStatus { + loading, + loaded, + failed, +} diff --git a/lib/network/api.dart b/lib/network/api.dart new file mode 100644 index 0000000000000000000000000000000000000000..23a620791df9af6319e2ac65bfac230a008e87ad --- /dev/null +++ b/lib/network/api.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import 'package:random/models/api_failure.dart'; + +class ApiService { + final Dio _dio = Dio(); + + final String baseUrl = 'https://tools.harrault.fr/tools/api'; + + Future<Response?> getData() async { + String url = baseUrl + '/get.php'; + try { + print('fetching api data... ' + url); + final Response? response = await _dio.get(url); + print('ok got api response.'); + print(response); + return response; + } on SocketException { + throw const ApiFailure(message: 'Failed to reach API endpoint.'); + } catch (err) { + print("Error (getData): $err"); + throw ApiFailure(message: "$err"); + } + } +} diff --git a/lib/repository/api.dart b/lib/repository/api.dart new file mode 100644 index 0000000000000000000000000000000000000000..ce3a0ef9c2f4a65f93a6b0b7074351ce5da54d9e --- /dev/null +++ b/lib/repository/api.dart @@ -0,0 +1,21 @@ +import 'package:random/models/api_data.dart'; +import 'package:random/network/api.dart'; + +class ApiRepository { + const ApiRepository({required this.apiService}); + + final ApiService apiService; + + Future<ApiData> getApiData() async { + print('(getApiData) delayed API call...'); + final response = await Future.delayed(Duration(milliseconds: 1000)) + .then((value) => apiService.getData()); + if (response != null) { + print('(getApiData) got api response'); + print(response.data); + return ApiData.fromJson(response.data); + } + print('(getApiData) failed'); + return ApiData.fromJson({}); + } +} diff --git a/lib/ui/screens/api_page.dart b/lib/ui/screens/api_page.dart new file mode 100644 index 0000000000000000000000000000000000000000..9b0253243a8513084a058be2fbcb056650d8927c --- /dev/null +++ b/lib/ui/screens/api_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:random/cubit/api_cubit.dart'; +import 'package:random/models/api_status.dart'; +import 'package:random/ui/widgets/api_data.dart'; +import 'package:random/ui/widgets/header_app.dart'; + +class ApiPage extends StatelessWidget { + const ApiPage({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.background, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 4), + physics: const BouncingScrollPhysics(), + children: <Widget>[ + SizedBox(height: 8), + AppHeader(text: 'api_page_title'), + SizedBox(height: 20), + BlocBuilder<ApiDataCubit, ApiDataState>( + builder: (BuildContext context, ApiDataState apiDataState) { + return ApiDataWidget( + data: apiDataState.data, + status: (apiDataState is ApiDataFetchLoading) + ? ApiStatus.loading + : (apiDataState is ApiDataFetchLoaded) + ? ApiStatus.loaded + : ApiStatus.failed, + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/ui/skeleton.dart b/lib/ui/skeleton.dart index 6492f6af94936851d4b0168ff4aa1b441c091872..f44393db9c3aee06006e962728fe4e521d7d5d8d 100644 --- a/lib/ui/skeleton.dart +++ b/lib/ui/skeleton.dart @@ -4,6 +4,7 @@ import 'package:flutter_swipe/flutter_swipe.dart'; import 'package:random/cubit/bottom_nav_cubit.dart'; import 'package:random/ui/screens/about_page.dart'; +import 'package:random/ui/screens/api_page.dart'; import 'package:random/ui/screens/demo_page.dart'; import 'package:random/ui/screens/graph_page.dart'; import 'package:random/ui/screens/settings_page.dart'; @@ -22,6 +23,7 @@ class _SkeletonScreenState extends State<SkeletonScreen> { Widget build(BuildContext context) { const List<Widget> pageNavigation = <Widget>[ DemoPage(), + ApiPage(), GraphPage(), SettingsPage(), AboutPage(), diff --git a/lib/ui/widgets/api_data.dart b/lib/ui/widgets/api_data.dart new file mode 100644 index 0000000000000000000000000000000000000000..36907922be8c5867e1d59bb1a91dc859eb885633 --- /dev/null +++ b/lib/ui/widgets/api_data.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:random/models/api_data.dart'; +import 'package:random/models/api_status.dart'; +import 'package:random/ui/widgets/error.dart'; + +class ApiDataWidget extends StatelessWidget { + const ApiDataWidget({ + super.key, + required this.status, + required this.data, + }); + + final ApiStatus status; + final ApiData? data; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('status: ' + status.toString()), + Text('number: ' + (this.data?.number.toString() ?? '')), + Text('md5: ' + (this.data?.md5.toString() ?? '')), + Text('updatedAt: ' + (this.data?.updatedAt.toString() ?? '')), + status == ApiStatus.loading + ? Container( + width: 12, + height: 12, + child: const CircularProgressIndicator(), + ) + : const SizedBox.shrink(), + status == ApiStatus.failed + ? ShowErrorWidget(message: 'state.failure.message') + : const SizedBox.shrink(), + ], + ); + } +} diff --git a/lib/ui/widgets/bottom_nav_bar.dart b/lib/ui/widgets/bottom_nav_bar.dart index 1349c7ec91661e5dd28e79c1d5a61ad72d3d27af..9ab56ea6333ba956d3e362b20bf75c9586cbacd8 100644 --- a/lib/ui/widgets/bottom_nav_bar.dart +++ b/lib/ui/widgets/bottom_nav_bar.dart @@ -46,6 +46,10 @@ class BottomNavBar extends StatelessWidget { icon: const Icon(UniconsLine.image), label: tr('bottom_nav_sample'), ), + BottomNavigationBarItem( + icon: const Icon(UniconsLine.globe), + label: tr('bottom_nav_api'), + ), BottomNavigationBarItem( icon: const Icon(UniconsLine.pen), label: tr('bottom_nav_chart'), diff --git a/lib/ui/widgets/header_app.dart b/lib/ui/widgets/header_app.dart index af80150eeac8fa18482af7b2bbeefe37fb54326d..b19ad444d879c83bb8b8bf3982a27d5bdcf1e16a 100644 --- a/lib/ui/widgets/header_app.dart +++ b/lib/ui/widgets/header_app.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:random/cubit/data_cubit.dart'; +import 'package:random/cubit/api_cubit.dart'; import 'package:random/cubit/settings_cubit.dart'; import 'package:random/models/interface_type.dart'; @@ -26,6 +27,8 @@ class AppHeader extends StatelessWidget { expertInterfaceIndicator(), SizedBox(width: 2), dataCounterIndicator(), + SizedBox(width: 2), + apiLoadingIndicator(), ], ); } @@ -47,4 +50,16 @@ class AppHeader extends StatelessWidget { }, ); } + + Widget apiLoadingIndicator() { + return BlocBuilder<ApiDataCubit, ApiDataState>( + builder: (context, apiDataState) { + return (apiDataState is ApiDataFetchLoading) + ? Text('⏳') + : (apiDataState is ApiDataFetchLoaded) + ? Text('✅') + : Text('⚠️'); + }, + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 131944004fd3d998e236c23c8eb60b8884508340..1b5533c24d0cf46bcaad81baa383d40e50b29b70 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7" + url: "https://pub.dev" + source: hosted + version: "5.3.3" easy_localization: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e20996dfc1f224bada21a6f3daa4fd1f52aeae5..614ad4a20d09270ad2000c910371483f6c373fef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: A random application, for testing purpose only. publish_to: 'none' -version: 1.0.36+37 +version: 1.0.37+38 environment: sdk: '^3.0.0' @@ -20,6 +20,7 @@ dependencies: unicons: ^2.1.1 package_info_plus: ^5.0.1 flutter_swipe: ^1.0.1 + dio: ^5.3.3 flutter: uses-material-design: false