diff --git a/README.md b/README.md index 97815799..beb7a6d7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ IPARKING_URL="https://.pl" WIREDASH_ID="<...>" # can be left empty WIREDASH_SECRET="<...>" # can be left empty SKS_URL="https://<...>/api/v1" +DIGITAL_GUIDE_URL="https://<...>/api" +DIGITAL_GUIDE_AUTHORIZATION_TOKEN="<...>" ``` If you need our server url please write us an email [kn.solvro@pwr.edu.pl](mailto:kn.solvro@pwr.edu.pl) or contact us via our [website](https://solvro.pwr.edu.pl/contact/) diff --git a/assets/svg/digital_guide/assistance_dog.svg b/assets/svg/digital_guide/assistance_dog.svg new file mode 100644 index 00000000..c52f6078 --- /dev/null +++ b/assets/svg/digital_guide/assistance_dog.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/svg/digital_guide/braille.svg b/assets/svg/digital_guide/braille.svg new file mode 100644 index 00000000..3b1505b5 --- /dev/null +++ b/assets/svg/digital_guide/braille.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/digital_guide/emergency_chairs.svg b/assets/svg/digital_guide/emergency_chairs.svg new file mode 100644 index 00000000..dff36401 --- /dev/null +++ b/assets/svg/digital_guide/emergency_chairs.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/svg/digital_guide/induction_loop.svg b/assets/svg/digital_guide/induction_loop.svg new file mode 100644 index 00000000..5e3c12f9 --- /dev/null +++ b/assets/svg/digital_guide/induction_loop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/svg/digital_guide/large_font.svg b/assets/svg/digital_guide/large_font.svg new file mode 100644 index 00000000..7a0261e4 --- /dev/null +++ b/assets/svg/digital_guide/large_font.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/svg/digital_guide/micronavigation.svg b/assets/svg/digital_guide/micronavigation.svg new file mode 100644 index 00000000..a6dc67ad --- /dev/null +++ b/assets/svg/digital_guide/micronavigation.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/svg/digital_guide/orientation_paths.svg b/assets/svg/digital_guide/orientation_paths.svg new file mode 100644 index 00000000..18a5bc15 --- /dev/null +++ b/assets/svg/digital_guide/orientation_paths.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/svg/digital_guide/sign_language.svg b/assets/svg/digital_guide/sign_language.svg new file mode 100644 index 00000000..3ede508f --- /dev/null +++ b/assets/svg/digital_guide/sign_language.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/svg/digital_guide/storey.svg b/assets/svg/digital_guide/storey.svg new file mode 100644 index 00000000..2f23acc9 --- /dev/null +++ b/assets/svg/digital_guide/storey.svg @@ -0,0 +1,3 @@ + + + diff --git a/example.env b/example.env index 9ada0206..5afa411d 100644 --- a/example.env +++ b/example.env @@ -3,4 +3,6 @@ ASSETS_URL="https://<...>" IPARKING_URL="https://<...>" WIREDASH_ID="<...>" WIREDASH_SECRET="<...>" -SKS_URL="<...>" \ No newline at end of file +SKS_URL="<...>" +DIGITAL_GUIDE_URL="<...>" +DIGITAL_GUIDE_AUTHORIZATION_TOKEN="<...>" \ No newline at end of file diff --git a/lib/api_base_rest/cache/cache.dart b/lib/api_base_rest/cache/cache.dart index fd531ceb..f230f4f2 100644 --- a/lib/api_base_rest/cache/cache.dart +++ b/lib/api_base_rest/cache/cache.dart @@ -1,37 +1,55 @@ import "dart:convert"; import "dart:typed_data"; +import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "../client/dio_client.dart"; + +import "../client/offline_error.dart"; import "cache_manager.dart"; extension DataCachingX on Ref { - Future getAndCacheData( + Future clearCache( String fullUrl, int ttlDays, - T Function(Map json) fromJson, - bool Function() extraValidityCheck, ) async { final cacheManager = watch(restCacheManagerProvider(ttlDays)); + await cacheManager.removeFile(fullUrl); + } + + Future getAndCacheData( + String fullUrl, + int ttlDays, + T Function(Map json) fromJson, { + // returns true if the data is still valid + required bool Function(T cachedData) extraValidityCheck, + required String Function(BuildContext context) localizedOfflineMessage, + VoidCallback? onRetry, + }) async { + final cacheManager = watch(restCacheManagerProvider(ttlDays)); final cachedFile = await cacheManager.getFileFromCache(fullUrl); - if (cachedFile != null && extraValidityCheck()) { + if (cachedFile != null) { final cachedData = await cachedFile.file.readAsString(); final data = fromJson( jsonDecode(cachedData) as Map, ); - return data; + if (extraValidityCheck(data)) { + return data; + } } - final dio = watch(restClientProvider); - final response = await dio.get(fullUrl); - final sksData = fromJson(response.data as Map); - - await cacheManager.putFile( + final response = await safeGetWatch( fullUrl, - Uint8List.fromList(jsonEncode(response.data).codeUnits), - fileExtension: CacheManagerConfig.jsonExtesion, + localizedMessage: localizedOfflineMessage, + onRetry: onRetry, ); - + final sksData = fromJson(response.data as Map); + if (extraValidityCheck(sksData)) { + await cacheManager.putFile( + fullUrl, + Uint8List.fromList(utf8.encode(jsonEncode(response.data))), + fileExtension: CacheManagerConfig.jsonExtesion, + ); + } return sksData; } } diff --git a/lib/api_base_rest/cache/cache_manager.dart b/lib/api_base_rest/cache/cache_manager.dart index d3af2082..ad783f0e 100644 --- a/lib/api_base_rest/cache/cache_manager.dart +++ b/lib/api_base_rest/cache/cache_manager.dart @@ -16,5 +16,5 @@ CacheManager restCacheManager(Ref ref, int ttlDays) { } abstract class CacheManagerConfig { - static const jsonExtesion = ".json"; + static const jsonExtesion = "json"; } diff --git a/lib/api_base_rest/client/offline_error.dart b/lib/api_base_rest/client/offline_error.dart new file mode 100644 index 00000000..03f3bb26 --- /dev/null +++ b/lib/api_base_rest/client/offline_error.dart @@ -0,0 +1,50 @@ +import "package:dio/dio.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "dio_client.dart"; + +class RestFrameworkOfflineException implements Exception { + final String message; + final String Function(BuildContext context) localizedMessage; + final VoidCallback? onRetry; + + RestFrameworkOfflineException({ + required this.localizedMessage, + this.onRetry, + this.message = "No Internet connection", + }); + + @override + String toString() => "RestFrameworkOfflineException: $message"; +} + +extension DioSafeRequestsX on Ref { + Future> safeRequest( + Future> Function() request, { + required String Function(BuildContext context) localizedMessage, + VoidCallback? onRetry, + }) async { + try { + return await request(); + } on DioException catch (_) { + throw RestFrameworkOfflineException( + localizedMessage: localizedMessage, + onRetry: onRetry, + ); + } + } + + Future> safeGetWatch( + String url, { + required String Function(BuildContext context) localizedMessage, + VoidCallback? onRetry, + }) async { + final dio = watch(restClientProvider); + return safeRequest( + () => dio.get(url), + localizedMessage: localizedMessage, + onRetry: onRetry, + ); + } +} diff --git a/lib/config/env.dart b/lib/config/env.dart index b3ba5328..b836a7bc 100644 --- a/lib/config/env.dart +++ b/lib/config/env.dart @@ -27,4 +27,9 @@ abstract class Env { static final String wiredashSecret = _Env.wiredashSecret; @EnviedField() static final String sksUrl = _Env.sksUrl; + @EnviedField() + static final String digitalGuideUrl = _Env.digitalGuideUrl; + @EnviedField() + static final String digitalGuideAuthorizationToken = + _Env.digitalGuideAuthorizationToken; } diff --git a/lib/config/ui_config.dart b/lib/config/ui_config.dart index 58fc4bfa..0e65fa4f 100644 --- a/lib/config/ui_config.dart +++ b/lib/config/ui_config.dart @@ -9,6 +9,12 @@ abstract class MyAppConfig { "\u{a9} 2024 Koło Naukowe Solvro, Politechnika Wrocławska"; } +abstract class AppWidgetsConfig { + static const paddingMedium = + EdgeInsets.symmetric(horizontal: 16, vertical: 12); + static const borderRadiusMedium = 8.0; +} + abstract class SplashScreenConfig { static const additionalWaitDuration = Duration(seconds: 1); static const animationDuration = Duration(milliseconds: 800); @@ -183,6 +189,7 @@ abstract class MyTooltipConfig { abstract class SksMenuConfig { static const borderRadius = 8.0; static const paddingSmall = 8.0; + static const paddingMedium = 12.0; static const paddingLarge = 16.0; static const sksDataSource = "https://sks.pwr.edu.pl/menu"; } @@ -195,12 +202,44 @@ abstract class SksConfig { static const outerPadding = EdgeInsets.only(right: 12, bottom: 2); } +abstract class SksChartConfig { + static const borderDashArray = 4.0; + static const borderRadius = 16.0; + static const paddingLarge = 16.0; + static const paddingMedium = 12.0; + static const paddingSmall = 8.0; + static const paddingExtraSmall = 4.0; + static const legendItemSize = 18.0; + static const heightSmall = 8.0; + static const heightMedium = 12.0; + static const heightLarge = 16.0; + static const paddingLargeLTR = EdgeInsets.only( + left: SksChartConfig.paddingLarge, + top: SksChartConfig.paddingLarge, + right: SksChartConfig.paddingLarge, + ); + static const sksChartDataUrl = "https://live.pwr.edu.pl/sks/"; + static const sksAddress = "Hoene-Wrońskiego 10"; + static const sksPostalCode = "50-370 Wrocław"; + static const buildingCode = "C-18"; +} + abstract class NavigationTabViewConfig { static const universalPadding = 12.0; static const radius = 8.0; static const navIconSize = 30.0; } +abstract class DigitalGuideConfig { + static const symetricalPaddingBig = + EdgeInsets.symmetric(vertical: 24, horizontal: 24); + static const borderRadiusMedium = 8.0; + static const heightSmall = 8.0; + static const heightBig = 24.0; + static const heightHuge = 48.0; + static const paddingMedium = 16.0; +} + abstract class AlertDialogConfig { static const horizontalPadding = 14.0; static const verticalPadding = 20.0; diff --git a/lib/features/digital_guide_view/amenities/presentation/amenities_expansion_tile_content.dart b/lib/features/digital_guide_view/amenities/presentation/amenities_expansion_tile_content.dart new file mode 100644 index 00000000..dc3ca6ab --- /dev/null +++ b/lib/features/digital_guide_view/amenities/presentation/amenities_expansion_tile_content.dart @@ -0,0 +1,64 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/widgets.dart"; + +import "../../../../gen/assets.gen.dart"; +import "../../../../utils/context_extensions.dart"; +import "../../../../utils/determine_contact_icon.dart"; +import "../../../../widgets/detail_views/contact_section.dart"; +import "../../general_info/data/models/digital_guide_response_extended.dart"; + +class AmenitiesExpansionTileContent extends StatelessWidget { + const AmenitiesExpansionTileContent({ + required this.digitalGuideResponseExtended, + }); + + final DigitalGuideResponseExtended digitalGuideResponseExtended; + + @override + Widget build(BuildContext context) { + return ContactSection( + list: [ + if (digitalGuideResponseExtended.canAssistanceDog) + ContactIconsModel( + text: context.localize.assistance_dog, + icon: Assets.svg.digitalGuide.assistanceDog, + ), + if (digitalGuideResponseExtended.isInductionLoop) + ContactIconsModel( + text: context.localize.induction_loop, + icon: Assets.svg.digitalGuide.inductionLoop, + ), + if (digitalGuideResponseExtended.isMicroNavigationSystem) + ContactIconsModel( + text: context.localize.micronavigation_system, + icon: Assets.svg.digitalGuide.micronavigation, + ), + if (digitalGuideResponseExtended.areGuidancePaths) + ContactIconsModel( + text: context.localize.orientation_paths, + icon: Assets.svg.digitalGuide.orientationPaths, + ), + if (digitalGuideResponseExtended.areBrailleBoards) + ContactIconsModel( + text: context.localize.information_boards_with_braille_description, + icon: Assets.svg.digitalGuide.braille, + ), + if (digitalGuideResponseExtended.areLargeFontBoards) + ContactIconsModel( + text: context.localize.information_boards_with_large_font, + icon: Assets.svg.digitalGuide.largeFont, + ), + if (digitalGuideResponseExtended.isSignLanguageInterpreter) + ContactIconsModel( + text: context.localize.sign_language_interpreter, + icon: Assets.svg.digitalGuide.signLanguage, + ), + if (digitalGuideResponseExtended.areEmergencyChairs) + ContactIconsModel( + text: context.localize.emergency_chairs, + icon: Assets.svg.digitalGuide.emergencyChairs, + ), + ].lock, + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/data/models/digital_guide_response.dart b/lib/features/digital_guide_view/general_info/data/models/digital_guide_response.dart new file mode 100644 index 00000000..09766d6b --- /dev/null +++ b/lib/features/digital_guide_view/general_info/data/models/digital_guide_response.dart @@ -0,0 +1,96 @@ +// ignore_for_file: invalid_annotation_target + +import "package:freezed_annotation/freezed_annotation.dart"; + +part "digital_guide_response.freezed.dart"; +part "digital_guide_response.g.dart"; + +@freezed +class DigitalGuideResponse with _$DigitalGuideResponse { + const factory DigitalGuideResponse({ + required int id, + required DigitalGuideTranslations translations, + @JsonKey(name: "number_of_storeys") required int numberOfStoreys, + @JsonKey( + name: "is_possibility_to_enter_with_assistance_dog", + fromJson: _stringToBool, + ) + required bool canAssistanceDog, + @JsonKey( + name: "is_induction_loop", + fromJson: _stringToBool, + ) + required bool isInductionLoop, + @JsonKey( + name: "is_micronavigation_system", + fromJson: _stringToBool, + ) + required bool isMicroNavigationSystem, + @JsonKey( + name: "are_guidance_paths", + fromJson: _stringToBool, + ) + required bool areGuidancePaths, + @JsonKey( + name: "are_information_boards_with_braille_description", + fromJson: _stringToBool, + ) + required bool areBrailleBoards, + @JsonKey( + name: "are_information_boards_with_large_font", + fromJson: _stringToBool, + ) + required bool areLargeFontBoards, + @JsonKey( + name: "is_sign_language_interpreter", + fromJson: _stringToBool, + ) + required bool isSignLanguageInterpreter, + @JsonKey( + name: "are_emergency_chairs", + fromJson: _stringToBool, + ) + required bool areEmergencyChairs, + @JsonKey(name: "telephone_number", fromJson: _formatPhoneNumbers) + required List phoneNumbers, + @JsonKey(name: "surrounding") required int surroundingId, + required List images, + String? imageUrl, + }) = _DigitalGuideResponse; + + factory DigitalGuideResponse.fromJson(Map json) => + _$DigitalGuideResponseFromJson(json); +} + +@freezed +class DigitalGuideTranslations with _$DigitalGuideTranslations { + const factory DigitalGuideTranslations({ + @JsonKey(name: "pl") required DigitalGuideTranslation plTranslation, + }) = _DigitalGuideTranslations; + + factory DigitalGuideTranslations.fromJson(Map json) => + _$DigitalGuideTranslationsFromJson(json); +} + +@freezed +class DigitalGuideTranslation with _$DigitalGuideTranslation { + const factory DigitalGuideTranslation({ + required String name, + @JsonKey(name: "extended_name") required String extendedName, + required String address, + }) = _DigitalGuideTranslation; + + factory DigitalGuideTranslation.fromJson(Map json) => + _$DigitalGuideTranslationFromJson(json); +} + +bool _stringToBool(String value) { + return value == "True"; +} + +List _formatPhoneNumbers(String phoneNumber) { + final matches = RegExp(r"\d{9}").allMatches( + phoneNumber.replaceAll("+48", "").replaceAll(RegExp(r"\D"), ""), + ); + return matches.map((match) => match.group(0)!).toList(); +} diff --git a/lib/features/digital_guide_view/general_info/data/models/digital_guide_response_extended.dart b/lib/features/digital_guide_view/general_info/data/models/digital_guide_response_extended.dart new file mode 100644 index 00000000..4ede202f --- /dev/null +++ b/lib/features/digital_guide_view/general_info/data/models/digital_guide_response_extended.dart @@ -0,0 +1,62 @@ +import "dart:core"; + +import "digital_guide_response.dart"; + +class DigitalGuideResponseExtended { + const DigitalGuideResponseExtended({ + required this.id, + required this.translations, + required this.numberOfStoreys, + required this.canAssistanceDog, + required this.isInductionLoop, + required this.isMicroNavigationSystem, + required this.areGuidancePaths, + required this.areBrailleBoards, + required this.areLargeFontBoards, + required this.isSignLanguageInterpreter, + required this.areEmergencyChairs, + required this.phoneNumbers, + required this.surroundingId, + required this.images, + required this.imageUrl, + }); + + final int id; + final DigitalGuideTranslations translations; + final int numberOfStoreys; + final bool canAssistanceDog; + final bool isInductionLoop; + final bool isMicroNavigationSystem; + final bool areGuidancePaths; + final bool areBrailleBoards; + final bool areLargeFontBoards; + final bool isSignLanguageInterpreter; + final bool areEmergencyChairs; + final List phoneNumbers; + final int surroundingId; + final List images; + final String? imageUrl; + + factory DigitalGuideResponseExtended.fromDigitalGuideResponse({ + required DigitalGuideResponse digitalGuideResponse, + required String? imageUrl, + }) { + return DigitalGuideResponseExtended( + id: digitalGuideResponse.id, + translations: digitalGuideResponse.translations, + numberOfStoreys: digitalGuideResponse.numberOfStoreys, + canAssistanceDog: digitalGuideResponse.canAssistanceDog, + isInductionLoop: digitalGuideResponse.isInductionLoop, + isMicroNavigationSystem: digitalGuideResponse.isMicroNavigationSystem, + areGuidancePaths: digitalGuideResponse.areGuidancePaths, + areBrailleBoards: digitalGuideResponse.areBrailleBoards, + areLargeFontBoards: digitalGuideResponse.areLargeFontBoards, + isSignLanguageInterpreter: digitalGuideResponse.isSignLanguageInterpreter, + areEmergencyChairs: digitalGuideResponse.areEmergencyChairs, + phoneNumbers: digitalGuideResponse.phoneNumbers, + surroundingId: digitalGuideResponse.surroundingId, + images: digitalGuideResponse.images, + imageUrl: imageUrl, + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/data/repository/digital_guide_repository.dart b/lib/features/digital_guide_view/general_info/data/repository/digital_guide_repository.dart new file mode 100644 index 00000000..ebe4a17f --- /dev/null +++ b/lib/features/digital_guide_view/general_info/data/repository/digital_guide_repository.dart @@ -0,0 +1,51 @@ +import "package:flutter/foundation.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +import "../../../../../api_base_rest/client/dio_client.dart"; +import "../../../../../config/env.dart"; +import "../models/digital_guide_response.dart"; +import "../models/digital_guide_response_extended.dart"; + +part "digital_guide_repository.g.dart"; + +@riverpod +Future getDigitalGuideData( + Ref ref, + int id, +) async { + final digitalGuideUrl = "${Env.digitalGuideUrl}/buildings/$id"; + final dio = ref.read(restClientProvider); + dio.options.headers["Authorization"] = + "Token ${Env.digitalGuideAuthorizationToken}"; + final response = await dio.get(digitalGuideUrl); + final digitalGuideResponse = + DigitalGuideResponse.fromJson(response.data as Map); + final imageUrl = await getImageUrl(ref, digitalGuideResponse.images[0]); + return DigitalGuideResponseExtended.fromDigitalGuideResponse( + digitalGuideResponse: digitalGuideResponse, + imageUrl: imageUrl, + ); +} + +@riverpod +Future getImageUrl(Ref ref, int id) async { + final digitalGuideUrl = "${Env.digitalGuideUrl}/images/$id"; + final dio = ref.read(restClientProvider); + dio.options.headers["Authorization"] = + "Token ${Env.digitalGuideAuthorizationToken}"; + + final response = await dio.get(digitalGuideUrl); + + // if only fetching image url fails I want data to be presented anyway + if (response.data is! Map) { + debugPrint("Failed to fetch image url!"); + return null; + } + + final Map responseData = + response.data as Map; + final imageUrl = responseData["image_960w"]; + + return imageUrl; +} diff --git a/lib/features/digital_guide_view/general_info/presentation/digital_guide_view.dart b/lib/features/digital_guide_view/general_info/presentation/digital_guide_view.dart new file mode 100644 index 00000000..38da56b8 --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/digital_guide_view.dart @@ -0,0 +1,131 @@ +import "package:auto_route/annotations.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../gen/assets.gen.dart"; +import "../../../../utils/context_extensions.dart"; +import "../../../../utils/determine_contact_icon.dart"; +import "../../../../widgets/detail_views/contact_section.dart"; +import "../../../../widgets/detail_views/detail_view_app_bar.dart"; +import "../../../../widgets/my_cached_image.dart"; +import "../../../../widgets/my_error_widget.dart"; +import "../data/models/digital_guide_response_extended.dart"; +import "../data/repository/digital_guide_repository.dart"; +import "widgets/accessibility_button.dart"; +import "widgets/digital_guide_data_source_link.dart"; +import "widgets/digital_guide_features_section.dart"; +import "widgets/headlines_section.dart"; +import "widgets/report_change_button.dart"; + +@RoutePage() +class DigitalGuideView extends ConsumerWidget { + const DigitalGuideView({ + @PathParam("id") required this.id, + }); + + final int id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncDigitalGuideData = ref.watch(getDigitalGuideDataProvider(id)); + // question: Should the app bar appear during loading or when there's an error? + // Now it doesn't, neither does it appear on SKS menu screen + return asyncDigitalGuideData.when( + data: _DigitalGuideView.new, + error: (error, stackTrace) => MyErrorWidget(error), + // TODO(Bartosh): shimmer loading + loading: () => const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } +} + +class _DigitalGuideView extends ConsumerWidget { + const _DigitalGuideView(this.digitalGuideResponseExtended); + + final DigitalGuideResponseExtended digitalGuideResponseExtended; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final widgets1 = [ + const SizedBox(height: DigitalGuideConfig.heightSmall), + MyCachedImage( + digitalGuideResponseExtended.imageUrl, + ), + HeadlinesSection( + // There is only Polish language translation in external API + // In the future we must think how to handle multiple translations in UI + // For now it can be temporarily dealt with in the data layer + name: digitalGuideResponseExtended.translations.plTranslation.name, + description: digitalGuideResponseExtended + .translations.plTranslation.extendedName, + ), + ContactSection( + list: IList([ + ContactIconsModel( + text: digitalGuideResponseExtended + .translations.plTranslation.address + .replaceAll("ulica", "ul."), + icon: Assets.svg.contactIcons.compass, + ), + ...digitalGuideResponseExtended.phoneNumbers.map( + (phoneNumber) => ContactIconsModel( + text: "+48$phoneNumber", + icon: Assets.svg.contactIcons.phone, + url: "tel:+48$phoneNumber", + ), + ), + ContactIconsModel( + text: context.localize + .storeys(digitalGuideResponseExtended.numberOfStoreys), + icon: Assets.svg.digitalGuide.storey, + ), + ]), + ), + const SizedBox(height: DigitalGuideConfig.heightBig), + ]; + + final widgets2 = [ + const SizedBox(height: DigitalGuideConfig.heightBig), + DigitalGuideDataSourceLink(), + ReportChangeButton(), + const SizedBox(height: DigitalGuideConfig.heightHuge), + ]; + + return Scaffold( + appBar: DetailViewAppBar( + actions: [ + AccessibilityButton(), + ], + ), + body: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return widgets1[index]; + }, + childCount: widgets1.length, + ), + ), + DigitalGuideFeaturesSection( + digitalGuideResponseExtended: digitalGuideResponseExtended, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return widgets2[index]; + }, + childCount: widgets2.length, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/presentation/widgets/accessibility_button.dart b/lib/features/digital_guide_view/general_info/presentation/widgets/accessibility_button.dart new file mode 100644 index 00000000..85fbe089 --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/widgets/accessibility_button.dart @@ -0,0 +1,32 @@ +import "package:flutter/material.dart"; + +import "../../../../../config/ui_config.dart"; +import "../../../../../theme/app_theme.dart"; + +class AccessibilityButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + DigitalGuideConfig.borderRadiusMedium, + ), + ), + side: BorderSide( + color: context.colorTheme.greyPigeon, + ), + backgroundColor: context.colorTheme.greyLight, + minimumSize: const Size(56, 32), + ), + child: Icon( + Icons.accessible, + color: context.colorTheme.blackMirage, + ), + ), + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_data_source_link.dart b/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_data_source_link.dart new file mode 100644 index 00000000..e91e8da9 --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_data_source_link.dart @@ -0,0 +1,46 @@ +import "package:flutter/gestures.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../../../config/ui_config.dart"; +import "../../../../../theme/app_theme.dart"; +import "../../../../../utils/context_extensions.dart"; +import "../../../../../utils/launch_url_util.dart"; + +class DigitalGuideDataSourceLink extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DigitalGuideConfig.paddingMedium, + ), + child: Text.rich( + TextSpan( + text: "${context.localize.data_come_from_website}: ", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + children: [ + TextSpan( + text: context.localize.digital_guide_website, + style: context.textTheme.bodyOrange.copyWith( + decoration: TextDecoration.underline, + decorationColor: context.colorTheme.orangePomegranade, + fontWeight: FontWeight.bold, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await ref.launch( + context.localize.digital_guide_website + .replaceAll("www.", "https://"), + ); + }, + ), + ], + ), + textAlign: TextAlign.center, + style: context.textTheme.body, + ), + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_features_section.dart b/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_features_section.dart new file mode 100644 index 00000000..704a7bf5 --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/widgets/digital_guide_features_section.dart @@ -0,0 +1,93 @@ +import "package:flutter/material.dart"; +import "package:flutter/widgets.dart"; + +import "../../../../../utils/context_extensions.dart"; +import "../../../../../widgets/my_expansion_tile.dart"; +import "../../../amenities/presentation/amenities_expansion_tile_content.dart"; +import "../../../localization/presentation/localization_expansion_tile_content.dart"; +import "../../../surrounding/presentation/surroundings_expansion_tile_content.dart"; +import "../../data/models/digital_guide_response_extended.dart"; + +typedef TileContent = ({String title, List content}); + +class DigitalGuideFeaturesSection extends StatelessWidget { + const DigitalGuideFeaturesSection({ + required this.digitalGuideResponseExtended, + }); + + final DigitalGuideResponseExtended digitalGuideResponseExtended; + + @override + Widget build(BuildContext context) { + final items = [ + ( + title: context.localize.localization, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.amenities, + content: [ + AmenitiesExpansionTileContent( + digitalGuideResponseExtended: digitalGuideResponseExtended, + ), + ], + ), + ( + title: context.localize.surroundings, + content: [ + SurroundingsExpansionTileContent( + digitalGuideResponseExtended: digitalGuideResponseExtended, + ), + ], + ), + ( + title: context.localize.transport, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.entrances, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.elevators, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.toilets, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.micro_navigation, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.building_structure, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.room_information, + content: [LocalizationExpansionTileContent()], + ), + ( + title: context.localize.evacuation, + content: [LocalizationExpansionTileContent()], + ), + ]; + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = items[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: MyExpansionTile( + title: item.title, + children: item.content, + ), + ); + }, + childCount: items.length, + ), + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/presentation/widgets/headlines_section.dart b/lib/features/digital_guide_view/general_info/presentation/widgets/headlines_section.dart new file mode 100644 index 00000000..0c60e01c --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/widgets/headlines_section.dart @@ -0,0 +1,30 @@ +import "package:flutter/widgets.dart"; + +import "../../../../../config/ui_config.dart"; + +class HeadlinesSection extends StatelessWidget { + const HeadlinesSection({ + required this.name, + required this.description, + }); + + final String name; + final String description; + + @override + Widget build(BuildContext context) { + return Padding( + padding: DigitalGuideConfig.symetricalPaddingBig, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24), + ), + if (description.isNotEmpty) Text(description), + ], + ), + ); + } +} diff --git a/lib/features/digital_guide_view/general_info/presentation/widgets/report_change_button.dart b/lib/features/digital_guide_view/general_info/presentation/widgets/report_change_button.dart new file mode 100644 index 00000000..00bed0a4 --- /dev/null +++ b/lib/features/digital_guide_view/general_info/presentation/widgets/report_change_button.dart @@ -0,0 +1,44 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../../../config/ui_config.dart"; +import "../../../../../theme/app_theme.dart"; +import "../../../../../utils/context_extensions.dart"; +import "../../../../../utils/launch_url_util.dart"; + +class ReportChangeButton extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: AppWidgetsConfig.paddingMedium, + child: Column( + children: [ + Text(context.localize.report_change_title), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async { + final emailUrl = + "mailto:${context.localize.report_change_email}?subject=${Uri.encodeComponent(context.localize.report_change_subject)}"; + unawaited(ref.launch(emailUrl)); + }, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorTheme.blueAzure, + padding: AppWidgetsConfig.paddingMedium, + minimumSize: const Size(144, 40), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(AppWidgetsConfig.borderRadiusMedium), + ), + ), + child: Text( + context.localize.report_change_button, + style: TextStyle(color: context.colorTheme.whiteSoap), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/digital_guide_view/localization/presentation/localization_expansion_tile_content.dart b/lib/features/digital_guide_view/localization/presentation/localization_expansion_tile_content.dart new file mode 100644 index 00000000..8f8dcec2 --- /dev/null +++ b/lib/features/digital_guide_view/localization/presentation/localization_expansion_tile_content.dart @@ -0,0 +1,8 @@ +import "package:flutter/widgets.dart"; + +class LocalizationExpansionTileContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const Text("Lorem ipsum text siuuuu Cristiano Rolando"); + } +} diff --git a/lib/features/digital_guide_view/readme.md b/lib/features/digital_guide_view/readme.md new file mode 100644 index 00000000..e8b7f135 --- /dev/null +++ b/lib/features/digital_guide_view/readme.md @@ -0,0 +1,17 @@ +# Requirements +* Two variables should be added to .env + * DIGITAL_GUIDE_URL + * DIGITAL_GUIDE_AUTHORIZATION_TOKEN + +# Tips +* All HTTP requests must include the authorization token ("Token ${Env.digitalGuideAuthorizationToken"}) +* The complete list of endpoints is available after logging under [this](https://przewodnik.pwr.edu.pl/swagger/) link + +# Used endpoints +1) Building data and image + * /general_info/data/repository/digita_guide_repository.dart + * DIGITAL_GUIDE_URL/buildings/{id} + * DIGITAL_GUIDE_URL/images/{id} +2) Surroundings data + * /surrounding/data/repository/surrounding_repository.dart + * DIGITAL_GUIDE_URL/surroundings/{id} diff --git a/lib/features/digital_guide_view/surrounding/data/models/surrounding_response.dart b/lib/features/digital_guide_view/surrounding/data/models/surrounding_response.dart new file mode 100644 index 00000000..765636cc --- /dev/null +++ b/lib/features/digital_guide_view/surrounding/data/models/surrounding_response.dart @@ -0,0 +1,37 @@ +// ignore_for_file: invalid_annotation_target + +import "package:freezed_annotation/freezed_annotation.dart"; + +part "surrounding_response.freezed.dart"; +part "surrounding_response.g.dart"; + +@freezed +class SurroundingResponse with _$SurroundingResponse { + const factory SurroundingResponse({ + required SurroundingResponseTranslations translations, + }) = _SurroundingResponse; + + factory SurroundingResponse.fromJson(Map json) => + _$SurroundingResponseFromJson(json); +} + +@freezed +class SurroundingResponseTranslations with _$SurroundingResponseTranslations { + const factory SurroundingResponseTranslations({ + @JsonKey(name: "pl") required SurroundingResponseTranslation translationPl, + }) = _SurroundingResponseTranslations; + + factory SurroundingResponseTranslations.fromJson(Map json) => + _$SurroundingResponseTranslationsFromJson(json); +} + +@freezed +class SurroundingResponseTranslation with _$SurroundingResponseTranslation { + const factory SurroundingResponseTranslation({ + @JsonKey(name: "are_parking_spaces_comment") + required String parkingSpacesComment, + }) = _SurroundingResponseTranslation; + + factory SurroundingResponseTranslation.fromJson(Map json) => + _$SurroundingResponseTranslationFromJson(json); +} diff --git a/lib/features/digital_guide_view/surrounding/data/repository/surrounding_repository.dart b/lib/features/digital_guide_view/surrounding/data/repository/surrounding_repository.dart new file mode 100644 index 00000000..3b57dd78 --- /dev/null +++ b/lib/features/digital_guide_view/surrounding/data/repository/surrounding_repository.dart @@ -0,0 +1,19 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +import "../../../../../api_base_rest/client/dio_client.dart"; +import "../../../../../config/env.dart"; +import "../models/surrounding_response.dart"; + +part "surrounding_repository.g.dart"; + +@riverpod +Future getSurroundingData(Ref ref, int id) async { + final surroundingUrl = "${Env.digitalGuideUrl}/surroundings/$id"; + final dio = ref.read(restClientProvider); + dio.options.headers["Authorization"] = + "Token ${Env.digitalGuideAuthorizationToken}"; + final response = await dio.get(surroundingUrl); + + return SurroundingResponse.fromJson(response.data as Map); +} diff --git a/lib/features/digital_guide_view/surrounding/presentation/surroundings_expansion_tile_content.dart b/lib/features/digital_guide_view/surrounding/presentation/surroundings_expansion_tile_content.dart new file mode 100644 index 00000000..7bc4e436 --- /dev/null +++ b/lib/features/digital_guide_view/surrounding/presentation/surroundings_expansion_tile_content.dart @@ -0,0 +1,54 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../../utils/context_extensions.dart"; +import "../../../../widgets/my_error_widget.dart"; +import "../../general_info/data/models/digital_guide_response_extended.dart"; +import "../data/models/surrounding_response.dart"; +import "../data/repository/surrounding_repository.dart"; + +class SurroundingsExpansionTileContent extends ConsumerWidget { + const SurroundingsExpansionTileContent({ + required this.digitalGuideResponseExtended, + }); + + final DigitalGuideResponseExtended digitalGuideResponseExtended; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncSurroundingData = ref.watch( + getSurroundingDataProvider(digitalGuideResponseExtended.surroundingId), + ); + + return asyncSurroundingData.when( + data: (surroundingData) => _SurroundingExpansionTileContent( + surroundingResponse: surroundingData, + ), + error: (error, stackTrace) => MyErrorWidget(error), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + ); + } +} + +class _SurroundingExpansionTileContent extends ConsumerWidget { + const _SurroundingExpansionTileContent({ + required this.surroundingResponse, + }); + + final SurroundingResponse surroundingResponse; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + Text( + context.localize.parking_location( + surroundingResponse.translations.translationPl.parkingSpacesComment, + ), + ), + ], + ); + } +} diff --git a/lib/features/guide_view/guide_view.dart b/lib/features/guide_view/guide_view.dart index b3cf7c0f..b0da88a3 100644 --- a/lib/features/guide_view/guide_view.dart +++ b/lib/features/guide_view/guide_view.dart @@ -6,6 +6,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "../../../../widgets/my_error_widget.dart"; import "../../config/ui_config.dart"; import "../../utils/context_extensions.dart"; +import "../../utils/launch_url_util.dart"; import "../../widgets/search_box_app_bar.dart"; import "../../widgets/wide_tile_card.dart"; import "../departments_view/widgets/departments_view_loading.dart"; @@ -58,7 +59,10 @@ class _GuideViewContent extends ConsumerWidget { AsyncValue(:final IList value) => GuideGrid( children: [ for (final item in value) GuideTile(item), - const _GuideInfo(), + _GuideInfo( + emailAddress: "kn.solvro@pwr.edu.pl", + subject: context.localize.guide_subject_default_content, + ), ].lock, ), _ => const Padding( @@ -69,14 +73,31 @@ class _GuideViewContent extends ConsumerWidget { } } -class _GuideInfo extends StatelessWidget { - const _GuideInfo(); +class _GuideInfo extends ConsumerWidget { + final String emailAddress; + final String? subject; + late final Uri emailLaunchUri; + + _GuideInfo({ + required this.emailAddress, + this.subject, + }) { + emailLaunchUri = Uri( + scheme: "mailto", + path: emailAddress, + query: "subject=$subject", + ); + } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return WideTileCard( title: context.localize.hi_student, - subtitle: context.localize.guide_development_info, + subtitle: context.localize.guide_ideas_info, + secondSubtitle: context.localize.guide_click_here, + onTap: () async { + await ref.launch(emailLaunchUri.toString()); + }, ); } } diff --git a/lib/features/home_view/home_view.dart b/lib/features/home_view/home_view.dart index 5c7d651b..d0f1eef7 100644 --- a/lib/features/home_view/home_view.dart +++ b/lib/features/home_view/home_view.dart @@ -46,7 +46,6 @@ class HomeView extends StatelessWidget { padding: EdgeInsets.only( left: horizontalPadding, // Align with the top bar right: safeAreaInsets.right, - bottom: HomeViewConfig.bottomPadding, ), child: KeepAliveHomeViewProviders( child: ListView.separated( diff --git a/lib/features/home_view/widgets/buildings_section/buildings_section.dart b/lib/features/home_view/widgets/buildings_section/buildings_section.dart index 2a1f888a..5ef96c76 100644 --- a/lib/features/home_view/widgets/buildings_section/buildings_section.dart +++ b/lib/features/home_view/widgets/buildings_section/buildings_section.dart @@ -37,9 +37,11 @@ class _BuildingsList extends ConsumerWidget { return switch (state) { AsyncError(:final error) => MyErrorWidget(error), AsyncValue(:final IList value) => SmallHorizontalPadding( - child: SizedBox( - height: 120, - child: _DataListBuildingsTiles(value), + child: MediumBottomPadding( + child: SizedBox( + height: 120, + child: _DataListBuildingsTiles(value), + ), ), ), _ => const MediumLeftPadding( diff --git a/lib/features/home_view/widgets/nav_actions_section.dart b/lib/features/home_view/widgets/nav_actions_section.dart index f181cfbf..a6571438 100644 --- a/lib/features/home_view/widgets/nav_actions_section.dart +++ b/lib/features/home_view/widgets/nav_actions_section.dart @@ -60,7 +60,7 @@ class _NavActionButton extends StatelessWidget { child: Ink( decoration: BoxDecoration( shape: BoxShape.circle, - gradient: context.colorTheme.toPwrGradient, + color: context.colorTheme.orangePomegranade, ), child: InkWell( onTap: onTap, diff --git a/lib/features/home_view/widgets/paddings.dart b/lib/features/home_view/widgets/paddings.dart index 8acb0a2f..908863a9 100644 --- a/lib/features/home_view/widgets/paddings.dart +++ b/lib/features/home_view/widgets/paddings.dart @@ -29,3 +29,12 @@ class MediumHorizontalPadding extends Padding { ), ); } + +class MediumBottomPadding extends Padding { + const MediumBottomPadding({super.key, super.child}) + : super( + padding: const EdgeInsets.only( + bottom: HomeViewConfig.paddingMedium, + ), + ); +} diff --git a/lib/features/home_view/widgets/parkings_section.dart b/lib/features/home_view/widgets/parkings_section.dart index 7e483173..b534a8c4 100644 --- a/lib/features/home_view/widgets/parkings_section.dart +++ b/lib/features/home_view/widgets/parkings_section.dart @@ -25,6 +25,10 @@ class ParkingsSection extends ConsumerWidget { actionTitle: context.localize.map_button, onClick: ref.navigateParkings, ), + FilledButton( + onPressed: ref.navigateToSksMenu, + child: const Text("navigate to sks menu"), + ), const _ParkingsList(), ], ); diff --git a/lib/features/home_view/widgets/science_clubs_section.dart b/lib/features/home_view/widgets/science_clubs_section.dart index 133d3a50..1d00a258 100644 --- a/lib/features/home_view/widgets/science_clubs_section.dart +++ b/lib/features/home_view/widgets/science_clubs_section.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -24,6 +26,12 @@ class ScienceClubsSection extends ConsumerWidget { actionTitle: context.localize.list, onClick: ref.navigateScienceClubs, ), + FilledButton( + onPressed: () { + unawaited(ref.navigateDigitalGuide(204)); + }, + child: const Text("Navigate to digital guide screen!"), + ), const _ScienceClubsList(), ], ); diff --git a/lib/features/map_view/controllers/map_controller.dart b/lib/features/map_view/controllers/map_controller.dart index 652eaf30..3169191f 100644 --- a/lib/features/map_view/controllers/map_controller.dart +++ b/lib/features/map_view/controllers/map_controller.dart @@ -17,7 +17,9 @@ class MyMapController { final _controllerCompleter = Completer(); Future get _controller => _controllerCompleter.future; void completeController(AnimatedMapController controller) { - _controllerCompleter.complete(controller); + if (!_controllerCompleter.isCompleted) { + _controllerCompleter.complete(controller); + } } Future zoomOnMarker(T item) async { diff --git a/lib/features/navigator/app_router.dart b/lib/features/navigator/app_router.dart index d2cc6ae9..041dc15e 100644 --- a/lib/features/navigator/app_router.dart +++ b/lib/features/navigator/app_router.dart @@ -7,6 +7,7 @@ import "../about_us_view/about_us_view.dart"; import "../buildings_view/buildings_view.dart"; import "../department_detail_view/department_detail_view.dart"; import "../departments_view/departments_view.dart"; +import "../digital_guide_view/general_info/presentation/digital_guide_view.dart"; import "../guide_detail_view/guide_detail_view.dart"; import "../guide_view/guide_view.dart"; import "../home_view/home_view.dart"; @@ -14,7 +15,7 @@ import "../navigation_tab_view/navigation_tab_view.dart"; import "../parkings_view/parkings_view.dart"; import "../science_club_detail_view/science_club_detail_view.dart"; import "../science_clubs_view/science_clubs_view.dart"; -import "../sks-menu/presentation/sks_menu_screen.dart"; +import "../sks_menu/presentation/sks_menu_screen.dart"; import "root_view.dart"; part "app_router.g.dart"; @@ -78,7 +79,7 @@ class AppRouter extends RootStackRouter { path: "/sks-menu", page: SksMenuRoute.page, ), - AutoRoute( + _NoTransitionRoute( path: "/departments", page: DepartmentsRoute.page, ), @@ -94,6 +95,10 @@ class AppRouter extends RootStackRouter { path: "/sci-clubs/:id", page: ScienceClubDetailRoute.page, ), + AutoRoute( + path: "/digital-guide/:id", + page: DigitalGuideRoute.page, + ), ]; } diff --git a/lib/features/navigator/utils/navigation_commands.dart b/lib/features/navigator/utils/navigation_commands.dart index 2cbe1df4..5c492ab6 100644 --- a/lib/features/navigator/utils/navigation_commands.dart +++ b/lib/features/navigator/utils/navigation_commands.dart @@ -76,4 +76,8 @@ extension NavigationX on WidgetRef { Future navigateToSksMenu() async { await _router.push(const SksMenuRoute()); } + + Future navigateDigitalGuide(int id) async { + await _router.push(DigitalGuideRoute(id: id)); + } } diff --git a/lib/features/parking_chart/widgets/chart_widget.dart b/lib/features/parking_chart/widgets/chart_widget.dart index 221ea8a8..5eea93f2 100644 --- a/lib/features/parking_chart/widgets/chart_widget.dart +++ b/lib/features/parking_chart/widgets/chart_widget.dart @@ -3,6 +3,7 @@ import "package:fl_chart/fl_chart.dart"; import "package:flutter/material.dart"; import "../../../theme/app_theme.dart"; +import "../../../widgets/chart_elements.dart"; import "../../parkings_view/models/parking.dart"; import "../chart_elements/chart_border.dart"; import "../chart_elements/chart_grid.dart"; @@ -30,8 +31,8 @@ class ChartWidget extends StatelessWidget { borderData: ChartBorder(context), gridData: ChartGrid(context), titlesData: FlTitlesData( - rightTitles: const _HideLabels(), - topTitles: const _HideLabels(), + rightTitles: const HideLabels(), + topTitles: const HideLabels(), bottomTitles: BottomLabels(context), leftTitles: LeftLabels(context), ), @@ -64,7 +65,3 @@ class ChartWidget extends StatelessWidget { ); } } - -class _HideLabels extends AxisTitles { - const _HideLabels() : super(sideTitles: const SideTitles()); -} diff --git a/lib/features/sks-menu/data/models/sks_menu_response.dart b/lib/features/sks-menu/data/models/sks_menu_response.dart deleted file mode 100644 index e17ed4aa..00000000 --- a/lib/features/sks-menu/data/models/sks_menu_response.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; - -import "sks_menu_data.dart"; - -part "sks_menu_response.freezed.dart"; -part "sks_menu_response.g.dart"; - -@freezed -class SksMenuResponse with _$SksMenuResponse { - const factory SksMenuResponse({ - required bool isMenuOnline, - required DateTime lastUpdate, - required List meals, - }) = _SksMenuResponse; - - factory SksMenuResponse.fromJson(Map json) => - _$SksMenuResponseFromJson(json); -} diff --git a/lib/features/sks-menu/data/repository/sks_menu_repository.dart b/lib/features/sks-menu/data/repository/sks_menu_repository.dart deleted file mode 100644 index 8cd7956a..00000000 --- a/lib/features/sks-menu/data/repository/sks_menu_repository.dart +++ /dev/null @@ -1,20 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:riverpod_annotation/riverpod_annotation.dart"; - -import "../../../../api_base_rest/client/dio_client.dart"; -import "../../../../config/env.dart"; -import "../models/sks_menu_response.dart"; - -part "sks_menu_repository.g.dart"; - -@riverpod -Future getSksMenuData(Ref ref) async { - final mealsUrl = "${Env.sksUrl}/meals/current"; - - final dio = ref.read(restClientProvider); - final response = await dio.get(mealsUrl); - final SksMenuResponse sksMenuResponse = - SksMenuResponse.fromJson(response.data as Map); - - return sksMenuResponse; -} diff --git a/lib/features/sks-menu/presentation/sks_menu_screen.dart b/lib/features/sks-menu/presentation/sks_menu_screen.dart deleted file mode 100644 index c06e5622..00000000 --- a/lib/features/sks-menu/presentation/sks_menu_screen.dart +++ /dev/null @@ -1,135 +0,0 @@ -import "dart:core"; - -import "package:auto_route/annotations.dart"; -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:logger/logger.dart"; -import "package:lottie/lottie.dart"; - -import "../../../../theme/app_theme.dart"; -import "../../../config/ui_config.dart"; -import "../../../gen/assets.gen.dart"; -import "../../../utils/context_extensions.dart"; -import "../../../widgets/detail_views/detail_view_app_bar.dart"; -import "../../home_view/widgets/paddings.dart"; -import "../../sks_people_live/presentation/widgets/sks_user_data_button.dart"; -import "../data/models/sks_menu_response.dart"; -import "../data/repository/sks_menu_repository.dart"; -import "widgets/sks_menu_data_source_link.dart"; -import "widgets/sks_menu_header.dart"; -import "widgets/sks_menu_section.dart"; - -@RoutePage() -class SksMenuView extends ConsumerWidget { - const SksMenuView({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asyncSksMenuData = ref.watch(getSksMenuDataProvider); - - return asyncSksMenuData.when( - data: (sksMenuData) => _SksMenuView( - asyncSksMenuData.value ?? - SksMenuResponse( - isMenuOnline: false, - lastUpdate: DateTime.now(), - meals: List.empty(), - ), - ), - error: (error, stackTrace) => _SKSMenuLottieAnimation(error: error), - loading: () => const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ), - ); - } -} - -class _SksMenuView extends StatelessWidget { - const _SksMenuView(this.sksMenuData); - - final SksMenuResponse sksMenuData; - @override - Widget build(BuildContext context) { - if (!sksMenuData.isMenuOnline) { - return const _SKSMenuLottieAnimation(); - } - return Scaffold( - appBar: DetailViewAppBar( - actions: const [ - SksUserDataButton(), - ], - ), - body: ListView( - children: [ - SksMenuHeader( - dateTimeOfLastUpdate: sksMenuData.lastUpdate.toIso8601String(), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: HomeViewConfig.paddingMedium, - ), - child: MediumHorizontalPadding( - child: SksMenuSection(sksMenuData.meals), - ), - ), - const SksMenuDataSourceLink(), - const SizedBox( - height: ScienceClubsViewConfig.mediumPadding, - ), - ], - ), - ); - } -} - -class _SKSMenuLottieAnimation extends StatelessWidget { - const _SKSMenuLottieAnimation({ - this.error, - }); - - final Object? error; - @override - Widget build(BuildContext context) { - Logger().e(error.toString()); - return Scaffold( - appBar: DetailViewAppBar( - actions: const [ - SksUserDataButton(), - ], - ), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox.square( - dimension: 200, - child: Lottie.asset( - Assets.animations.sksClosed, - fit: BoxFit.cover, - repeat: false, - frameRate: const FrameRate(LottieAnimationConfig.frameRate), - renderCache: RenderCache.drawingCommands, - ), - ), - Align( - child: Text( - context.localize.sks_menu_closed, - style: context.textTheme.headline, - textAlign: TextAlign.center, - ), - ), - if (error != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - error.toString(), - style: context.textTheme.titleGrey, - textAlign: TextAlign.center, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/sks-menu/presentation/widgets/sks_menu_data_source_link.dart b/lib/features/sks-menu/presentation/widgets/sks_menu_data_source_link.dart deleted file mode 100644 index eaebd5bf..00000000 --- a/lib/features/sks-menu/presentation/widgets/sks_menu_data_source_link.dart +++ /dev/null @@ -1,36 +0,0 @@ -import "package:flutter/cupertino.dart"; -import "package:flutter/gestures.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "../../../../config/ui_config.dart"; -import "../../../../theme/app_theme.dart"; -import "../../../../utils/context_extensions.dart"; -import "../../../../utils/launch_url_util.dart"; - -class SksMenuDataSourceLink extends ConsumerWidget { - const SksMenuDataSourceLink({ - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Text.rich( - TextSpan( - text: "${context.localize.data_come_from_website}: ", - children: [ - TextSpan( - text: SksMenuConfig.sksDataSource.replaceFirst("https://", "www."), - style: context.textTheme.bodyOrange.copyWith( - decoration: TextDecoration.underline, - decorationColor: context.colorTheme.orangePomegranade, - ), - recognizer: TapGestureRecognizer() - ..onTap = () async => ref.launch(SksMenuConfig.sksDataSource), - ), - ], - ), - textAlign: TextAlign.center, - style: context.textTheme.body, - ); - } -} diff --git a/lib/features/sks_chart/data/models/sks_chart_data.dart b/lib/features/sks_chart/data/models/sks_chart_data.dart new file mode 100644 index 00000000..dd378ea9 --- /dev/null +++ b/lib/features/sks_chart/data/models/sks_chart_data.dart @@ -0,0 +1,27 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "sks_chart_data.freezed.dart"; +part "sks_chart_data.g.dart"; + +@freezed +class SksChartData with _$SksChartData { + const factory SksChartData({ + required int activeUsers, + required int movingAverage21, + required DateTime externalTimestamp, + }) = _SksChartData; + + factory SksChartData.fromJson(Map json) => + _$SksChartDataFromJson(json); +} + +extension SksChartDataIListX on IList { + double get maxNumberOfUsers { + return map( + (data) => data.activeUsers > data.movingAverage21 + ? data.activeUsers + : data.movingAverage21, + ).reduce((a, b) => a > b ? a : b).toDouble(); + } +} diff --git a/lib/features/sks_chart/data/repository/sks_chart_repository.dart b/lib/features/sks_chart/data/repository/sks_chart_repository.dart new file mode 100644 index 00000000..9120c7b1 --- /dev/null +++ b/lib/features/sks_chart/data/repository/sks_chart_repository.dart @@ -0,0 +1,24 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +import "../../../../api_base_rest/client/dio_client.dart"; +import "../../../../config/env.dart"; +import "../models/sks_chart_data.dart"; + +part "sks_chart_repository.g.dart"; + +@riverpod +Future> getLatestChartData(Ref ref) async { + final dio = ref.watch(restClientProvider); + final latestChartDataUrl = "${Env.sksUrl}/sks-users/today/"; + final response = await dio.get(latestChartDataUrl); + final data = response.data as List; + final chartDataList = data + .map( + (entry) => SksChartData.fromJson(entry as Map), + ) + .toIList(); + + return chartDataList; +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart.dart new file mode 100644 index 00000000..70cd0005 --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart.dart @@ -0,0 +1,99 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:fl_chart/fl_chart.dart"; +import "package:flutter/material.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../theme/app_theme.dart"; +import "../../../../theme/colors.dart"; +import "../../../../widgets/chart_elements.dart"; +import "../../data/models/sks_chart_data.dart"; +import "sks_chart_grid_data.dart"; +import "sks_chart_labels.dart"; +import "sks_chart_line_touch_data.dart"; + +class SksChart extends StatelessWidget { + const SksChart({ + required this.maxNumberOfUsers, + required this.chartData, + }); + + final double maxNumberOfUsers; + final IList chartData; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: SksChartConfig.paddingLarge), + child: AspectRatio( + aspectRatio: 1.5, + child: LineChart( + duration: Duration.zero, + LineChartData( + clipData: const FlClipData.all(), + backgroundColor: context.colorTheme.whiteSoap, + gridData: SksChartGridData(context), + maxY: maxNumberOfUsers + (maxNumberOfUsers / 10).toInt(), + lineBarsData: [ + LineChartBarData( + belowBarData: BarAreaData( + show: true, + gradient: ColorsConsts.toPwrGradient, + applyCutOffY: true, + ), + isCurved: true, + color: context.colorTheme.orangePomegranade, + dotData: FlDotData( + checkToShowDot: (FlSpot spot, LineChartBarData barData) { + return false; + }, + ), + spots: chartData.asMap().entries.map((e) { + if (e.value.externalTimestamp.isAfter(DateTime.now())) { + return FlSpot.nullSpot; + } else { + return FlSpot( + e.key.toDouble(), + e.value.activeUsers.toDouble(), + ); + } + }).toList(), + ), + LineChartBarData( + isCurved: true, + dashArray: [ + SksChartConfig.borderDashArray.toInt(), + SksChartConfig.borderDashArray.toInt(), + ], + dotData: FlDotData( + checkToShowDot: (FlSpot spot, LineChartBarData barData) { + return false; + }, + ), + color: context.colorTheme.blueAzure, + spots: chartData.asMap().entries.map((e) { + return FlSpot( + e.key.toDouble(), + e.value.movingAverage21.toDouble(), + ); + }).toList(), + ), + ], + titlesData: FlTitlesData( + topTitles: const HideLabels(), + rightTitles: const HideLabels(), + leftTitles: SksChartRightTiles(context), + bottomTitles: SksChartBottomTitles( + context, + chartData, + ), + ), + lineTouchData: SksChartLineTouchData( + context, + chartData.map((e) => e.externalTimestamp).toIList(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart_grid_data.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart_grid_data.dart new file mode 100644 index 00000000..ce58ee5b --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart_grid_data.dart @@ -0,0 +1,14 @@ +import "package:fl_chart/fl_chart.dart"; +import "package:flutter/cupertino.dart"; + +import "../../../parking_chart/chart_elements/chart_grid.dart"; + +class SksChartGridData extends FlGridData { + SksChartGridData(BuildContext context) + : super( + verticalInterval: 100, + horizontalInterval: 25, + getDrawingHorizontalLine: (value) => GridLine(context), + getDrawingVerticalLine: (value) => GridLine(context), + ); +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart_header.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart_header.dart new file mode 100644 index 00000000..fead65af --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart_header.dart @@ -0,0 +1,55 @@ +import "package:flutter/cupertino.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../theme/app_theme.dart"; +import "../../../../utils/context_extensions.dart"; +import "../../../sks_people_live/data/models/sks_user_data.dart"; +import "../../../sks_people_live/presentation/widgets/sks_user_data_button.dart"; + +// TODO(mikolaj-jalocha): add navigation? maybe after click on building informations + +class SksChartHeader extends StatelessWidget { + const SksChartHeader({ + super.key, + required this.numberOfPeople, + required this.trend, + }); + + final String numberOfPeople; + final Trend? trend; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + SksChartConfig.buildingCode, + style: context.textTheme.headline, + ), + Text( + "${context.localize.street_abbreviation} ${SksChartConfig.sksAddress}", + style: context.textTheme.body, + ), + Text(SksChartConfig.sksPostalCode, style: context.textTheme.body), + ], + ), + Row( + children: [ + Text( + numberOfPeople, + style: context.textTheme.body.copyWith(fontSize: 18), + ), + const SizedBox( + width: SksChartConfig.heightSmall, + ), + trend?.icon ?? const SizedBox.shrink(), + ], + ), + ], + ); + } +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart_labels.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart_labels.dart new file mode 100644 index 00000000..620f7d07 --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart_labels.dart @@ -0,0 +1,71 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:fl_chart/fl_chart.dart"; +import "package:flutter/cupertino.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../theme/app_theme.dart"; +import "../../../../utils/context_extensions.dart"; +import "../../../../utils/datetime_utils.dart"; +import "../../data/models/sks_chart_data.dart"; + +class SksChartRightTiles extends AxisTitles { + SksChartRightTiles(BuildContext context) + : super( + axisNameWidget: Text( + context.localize.sks_chart_number_of_users, + style: context.textTheme.body + .copyWith(fontSize: 14, fontWeight: FontWeight.w400), + ), + sideTitles: SideTitles( + maxIncluded: false, + reservedSize: 40, + showTitles: true, + getTitlesWidget: (double value, TitleMeta meta) { + return Center( + child: Text( + "${value.toInt()}", + style: context.textTheme.body + .copyWith(fontSize: 12, fontWeight: FontWeight.w400), + ), + ); + }, + ), + ); +} + +class SksChartBottomTitles extends AxisTitles { + SksChartBottomTitles(BuildContext context, IList chartData) + : super( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 35, + interval: 5, + getTitlesWidget: (double value, TitleMeta meta) { + final String text = (chartData.isNotEmpty) + ? chartData[value.toInt()] + .externalTimestamp + .toLocal() + .minute == + 0 + ? chartData[value.toInt()] + .externalTimestamp + .toLocal() + .toHourMinuteString() + : "" + : ""; + + return Padding( + padding: const EdgeInsets.only( + top: SksChartConfig.paddingSmall, + left: 50, + ), + child: Text( + style: context.textTheme.body + .copyWith(fontSize: 12, fontWeight: FontWeight.w400), + text, + ), + ); + }, + ), + ); +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart_legend.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart_legend.dart new file mode 100644 index 00000000..8c1dd77a --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart_legend.dart @@ -0,0 +1,67 @@ +import "package:dotted_border/dotted_border.dart"; +import "package:flutter/cupertino.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../theme/app_theme.dart"; +import "../../../../utils/context_extensions.dart"; + +class SksChartLegend extends StatelessWidget { + const SksChartLegend({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SksChartLegendItem( + text: context.localize.measured_number_of_users, + isPredicted: false, + ), + SksChartLegendItem( + text: context.localize.forecasted_number_of_users, + isPredicted: true, + ), + ], + ); + } +} + +class SksChartLegendItem extends StatelessWidget { + const SksChartLegendItem({required this.text, required this.isPredicted}); + + final String text; + final bool isPredicted; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (isPredicted) + DottedBorder( + borderType: BorderType.RRect, + dashPattern: const [SksChartConfig.borderDashArray], + color: context.colorTheme.blueAzure, + padding: EdgeInsets.zero, + // ignore: sized_box_for_whitespace + child: Container( + width: SksChartConfig.legendItemSize, + ), + ) + else + Container( + width: SksChartConfig.legendItemSize, + height: 2, + color: context.colorTheme.orangePomegranade, + ), + const SizedBox( + width: SksChartConfig.heightMedium, + ), + Text( + text, + style: context.textTheme.body, + ), + ], + ); + } +} diff --git a/lib/features/sks_chart/presentation/chart_elements/sks_chart_line_touch_data.dart b/lib/features/sks_chart/presentation/chart_elements/sks_chart_line_touch_data.dart new file mode 100644 index 00000000..2f0afb06 --- /dev/null +++ b/lib/features/sks_chart/presentation/chart_elements/sks_chart_line_touch_data.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:fl_chart/fl_chart.dart"; +import "package:flutter/cupertino.dart"; + +import "../../../../theme/app_theme.dart"; +import "../../../../utils/datetime_utils.dart"; + +// TODO(mikolaj-jalocha): Sometimes hour label is displayed at the bottom, sometimes between the 2 values. +// TODO(mikolaj-jalocha): Make hour label be always in black (now is red if values were measured, blue otherwise) + +class SksChartLineTouchData extends LineTouchData { + final IList dateTime; + + SksChartLineTouchData(BuildContext context, this.dateTime) + : super( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (LineBarSpot lineBarSpot) => + context.colorTheme.greyLight, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((LineBarSpot touchedSpot) { + final hour = (touchedSpot.barIndex == 0 || + touchedSpots.length == 1) + ? "\n${dateTime.get(touchedSpot.x.toInt()).toHourMinuteString()}" + : ""; + final value = touchedSpot.y.toStringAsFixed(0) + hour; + final Color color = (touchedSpot.barIndex == 0) + ? context.colorTheme.orangePomegranade + : context.colorTheme.blueAzure; + return LineTooltipItem( + value, + TextStyle(color: color), + ); + }).toList(); + }, + ), + ); +} diff --git a/lib/features/sks_chart/presentation/sks_chart_card.dart b/lib/features/sks_chart/presentation/sks_chart_card.dart new file mode 100644 index 00000000..223c8618 --- /dev/null +++ b/lib/features/sks_chart/presentation/sks_chart_card.dart @@ -0,0 +1,62 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/cupertino.dart"; + +import "../../../config/ui_config.dart"; +import "../../../theme/app_theme.dart"; +import "../../sks_people_live/data/models/sks_user_data.dart"; +import "../data/models/sks_chart_data.dart"; +import "chart_elements/sks_chart.dart"; +import "chart_elements/sks_chart_header.dart"; +import "chart_elements/sks_chart_legend.dart"; + +class SksChartCard extends StatelessWidget { + const SksChartCard({ + super.key, + required this.currentNumberOfUsers, + required this.maxNumberOfUsers, + required this.chartData, + }); + + final SksUserData? currentNumberOfUsers; + final double maxNumberOfUsers; + final IList chartData; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.colorTheme.greyLight, + borderRadius: BorderRadius.circular(SksChartConfig.borderRadius), + ), + child: Padding( + padding: SksChartConfig.paddingLargeLTR, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SksChartHeader( + numberOfPeople: + currentNumberOfUsers?.activeUsers.toString() ?? "", + trend: currentNumberOfUsers?.trend, + ), + const SizedBox( + height: SksChartConfig.heightLarge, + ), + SksChart( + maxNumberOfUsers: maxNumberOfUsers, + chartData: chartData, + ), + const Padding( + padding: EdgeInsets.only( + left: SksChartConfig.paddingLarge, + bottom: SksChartConfig.paddingLarge, + top: SksChartConfig.paddingSmall, + ), + child: SksChartLegend(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/sks_chart/presentation/sks_chart_sheet.dart b/lib/features/sks_chart/presentation/sks_chart_sheet.dart new file mode 100644 index 00000000..ad477052 --- /dev/null +++ b/lib/features/sks_chart/presentation/sks_chart_sheet.dart @@ -0,0 +1,96 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../config/ui_config.dart"; +import "../../../theme/app_theme.dart"; +import "../../../utils/context_extensions.dart"; +import "../../../widgets/my_error_widget.dart"; +import "../../../widgets/text_and_url_widget.dart"; +import "../../bottom_scroll_sheet/drag_handle.dart"; +import "../../sks_people_live/data/repository/latest_sks_user_data_repo.dart"; +import "../data/models/sks_chart_data.dart"; +import "../data/repository/sks_chart_repository.dart"; +import "sks_chart_card.dart"; + +// TODO(mikolaj-jalocha): create shimmer loading + +class SksChartSheet extends ConsumerWidget { + const SksChartSheet({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncChartData = ref.watch(getLatestChartDataProvider); + final asyncNumberOfUsers = ref.watch(getLatestSksUserDataProvider); + + final currentNumberOfUsers = asyncNumberOfUsers.value; + final maxNumberOfUsers = asyncChartData.value?.maxNumberOfUsers ?? 0; + + return switch (asyncChartData) { + AsyncError(:final error) => MyErrorWidget(error), + AsyncLoading() => const SizedBox.shrink(), + AsyncValue() => Padding( + padding: const EdgeInsets.symmetric( + vertical: SksChartConfig.paddingExtraSmall, + ), + child: Column( + children: [ + Padding( + padding: SksChartConfig.paddingLargeLTR + .copyWith(bottom: SksChartConfig.paddingMedium), + child: const _SksSheetHeader(), + ), + Expanded( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + SksChartConfig.paddingMedium, + SksChartConfig.paddingMedium, + SksChartConfig.paddingMedium, + 0, + ), + child: SksChartCard( + currentNumberOfUsers: currentNumberOfUsers, + maxNumberOfUsers: maxNumberOfUsers, + chartData: asyncChartData.value ?? const IList.empty(), + ), + ), + Padding( + padding: const EdgeInsets.all( + SksChartConfig.paddingSmall, + ), + child: TextAndUrl( + SksChartConfig.sksChartDataUrl, + "${context.localize.data_come_from_website}: ", + ), + ), + ], + ), + ), + ], + ), + ), + }; + } +} + +class _SksSheetHeader extends StatelessWidget { + const _SksSheetHeader(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const LineHandle(), + const SizedBox(height: SksChartConfig.heightSmall), + Text( + context.localize.sks_chart_title, + style: context.textTheme.headline, + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/features/sks-menu/data/models/dish_category_enum.dart b/lib/features/sks_menu/data/models/dish_category_enum.dart similarity index 83% rename from lib/features/sks-menu/data/models/dish_category_enum.dart rename to lib/features/sks_menu/data/models/dish_category_enum.dart index c78d7d8d..abbc4127 100644 --- a/lib/features/sks-menu/data/models/dish_category_enum.dart +++ b/lib/features/sks_menu/data/models/dish_category_enum.dart @@ -10,7 +10,8 @@ enum DishCategory { vegetarianDish, meatDish, sideDish, - drink; + drink, + technicalInfo; @override String toString() => name; @@ -25,7 +26,8 @@ extension GetLocalizedNameX on DishCategory { context.localize.sks_menu_vegetarian_dishes, DishCategory.meatDish => context.localize.sks_menu_meat_dishes, DishCategory.sideDish => context.localize.sks_menu_side_dishes, - DishCategory.drink => context.localize.sks_menu_drinks + DishCategory.drink => context.localize.sks_menu_drinks, + DishCategory.technicalInfo => context.localize.sks_menu_technical_info, }; } } diff --git a/lib/features/sks-menu/data/models/sks_menu_data.dart b/lib/features/sks_menu/data/models/sks_menu_data.dart similarity index 100% rename from lib/features/sks-menu/data/models/sks_menu_data.dart rename to lib/features/sks_menu/data/models/sks_menu_data.dart diff --git a/lib/features/sks_menu/data/models/sks_menu_response.dart b/lib/features/sks_menu/data/models/sks_menu_response.dart new file mode 100644 index 00000000..32c5063e --- /dev/null +++ b/lib/features/sks_menu/data/models/sks_menu_response.dart @@ -0,0 +1,32 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +import "sks_menu_data.dart"; + +part "sks_menu_response.freezed.dart"; +part "sks_menu_response.g.dart"; + +@freezed +class SksMenuResponse with _$SksMenuResponse { + const factory SksMenuResponse({ + required bool isMenuOnline, + required DateTime lastUpdate, + required List meals, + }) = _SksMenuResponse; + + factory SksMenuResponse.fromJson(Map json) => + _$SksMenuResponseFromJson(json); +} + +@freezed +class ExtendedSksMenuResponse with _$ExtendedSksMenuResponse { + const factory ExtendedSksMenuResponse({ + required bool isMenuOnline, + required DateTime lastUpdate, + required IList meals, + required IList technicalInfos, + }) = _ExtendedSksMenuResponse; + + factory ExtendedSksMenuResponse.fromJson(Map json) => + _$ExtendedSksMenuResponseFromJson(json); +} diff --git a/lib/features/sks_menu/data/repository/sks_menu_repository.dart b/lib/features/sks_menu/data/repository/sks_menu_repository.dart new file mode 100644 index 00000000..e28eb940 --- /dev/null +++ b/lib/features/sks_menu/data/repository/sks_menu_repository.dart @@ -0,0 +1,52 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +import "../../../../api_base_rest/cache/cache.dart"; +import "../../../../config/env.dart"; +import "../../../../utils/datetime_utils.dart"; +import "../../presentation/sks_menu_screen.dart"; +import "../models/dish_category_enum.dart"; +import "../models/sks_menu_response.dart"; + +part "sks_menu_repository.g.dart"; + +@riverpod +class SksMenuRepository extends _$SksMenuRepository { + static final _mealsUrl = "${Env.sksUrl}/meals/current"; + static const _ttlDays = 1; + + Future clearCache() async { + return ref.clearCache(_mealsUrl, _ttlDays); + } + + @override + Future build() async { + final sksMenuResponse = await ref.getAndCacheData( + _mealsUrl, + _ttlDays, + SksMenuResponse.fromJson, + extraValidityCheck: (data) { + return data.isMenuOnline && + DateTime.now().date.isSameDay(data.lastUpdate.date); + }, + localizedOfflineMessage: SksMenuView.localizedOfflineMessage, + onRetry: () => ref.invalidateSelf(), + ); + + final trueMeals = sksMenuResponse.meals + .where((e) => e.category != DishCategory.technicalInfo) + .toIList(); + + final technicalInfos = sksMenuResponse.meals + .where((e) => e.category == DishCategory.technicalInfo) + .map((e) => e.name) + .toIList(); + + return ExtendedSksMenuResponse( + isMenuOnline: sksMenuResponse.isMenuOnline, + lastUpdate: sksMenuResponse.lastUpdate, + meals: trueMeals, + technicalInfos: technicalInfos, + ); + } +} diff --git a/lib/features/sks_menu/presentation/sks_menu_screen.dart b/lib/features/sks_menu/presentation/sks_menu_screen.dart new file mode 100644 index 00000000..812c417d --- /dev/null +++ b/lib/features/sks_menu/presentation/sks_menu_screen.dart @@ -0,0 +1,205 @@ +import "dart:core"; + +import "package:auto_route/annotations.dart"; +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:lottie/lottie.dart"; + +import "../../../../theme/app_theme.dart"; +import "../../../config/ui_config.dart"; +import "../../../gen/assets.gen.dart"; +import "../../../utils/context_extensions.dart"; +import "../../../widgets/detail_views/detail_view_app_bar.dart"; +import "../../../widgets/my_error_widget.dart"; +import "../../../widgets/my_text_button.dart"; +import "../../../widgets/text_and_url_widget.dart"; +import "../../sks_people_live/presentation/widgets/sks_user_data_button.dart"; +import "../data/models/sks_menu_response.dart"; +import "../data/repository/sks_menu_repository.dart"; +import "widgets/sks_menu_header.dart"; +import "widgets/sks_menu_section.dart"; +import "widgets/sks_menu_view_loading.dart"; +import "widgets/technical_message.dart"; + +@RoutePage() +class SksMenuView extends HookConsumerWidget { + const SksMenuView({super.key}); + + static String localizedOfflineMessage(BuildContext context) { + return context.localize.my_offline_error_message( + context.localize.sks_menu, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncSksMenuData = ref.watch(sksMenuRepositoryProvider); + final isLastMenuButtonClicked = useState(false); + + return asyncSksMenuData.when( + data: (sksMenuData) { + if (!sksMenuData.isMenuOnline && !isLastMenuButtonClicked.value) { + return _SKSMenuUnavailableAnimation( + onShowLastMenuTap: () { + isLastMenuButtonClicked.value = true; + }, + ); + } + return _SksMenuView( + sksMenuData: sksMenuData, + isLastMenuButtonClicked: isLastMenuButtonClicked.value, + ); + }, + error: (error, stackTrace) => Scaffold( + appBar: DetailViewAppBar( + actions: const [ + SksUserDataButton(), + ], + ), + body: MyErrorWidget(error), + ), + loading: () => const Scaffold( + body: Center( + child: SksMenuViewLoading(), + ), + ), + ); + } +} + +class _SksMenuView extends ConsumerWidget { + const _SksMenuView({ + required this.sksMenuData, + required this.isLastMenuButtonClicked, + }); + + final ExtendedSksMenuResponse sksMenuData; + final bool isLastMenuButtonClicked; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!isLastMenuButtonClicked && !sksMenuData.isMenuOnline) { + return const _SKSMenuUnavailableAnimation(); + } + return Scaffold( + appBar: DetailViewAppBar( + actions: const [ + SksUserDataButton(), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + await ref.read(sksMenuRepositoryProvider.notifier).clearCache(); + return ref.refresh(sksMenuRepositoryProvider.future); + }, + color: context.colorTheme.orangePomegranade, + child: ListView( + children: [ + if (!sksMenuData.isMenuOnline) + TechnicalMessage( + alertType: AlertType.info, + title: context.localize.sks_note, + message: context.localize.sks_menu_you_see_last_menu, + ), + for (final technicalInfo in sksMenuData.technicalInfos) + TechnicalMessage(message: technicalInfo), + SksMenuHeader( + dateTimeOfLastUpdate: sksMenuData.lastUpdate.toIso8601String(), + ), + Padding( + padding: const EdgeInsets.all(HomeViewConfig.paddingMedium), + child: SksMenuSection(sksMenuData.meals), + ), + TextAndUrl( + SksMenuConfig.sksDataSource, + "${context.localize.data_come_from_website}: ", + ), + const SizedBox( + height: ScienceClubsViewConfig.mediumPadding, + ), + ], + ), + ), + ); + } +} + +class _SKSMenuUnavailableAnimation extends HookWidget { + const _SKSMenuUnavailableAnimation({this.onShowLastMenuTap}); + + final VoidCallback? onShowLastMenuTap; + + @override + Widget build(BuildContext context) { + final isAnimationCompleted = useState(false); + final animationSize = MediaQuery.sizeOf(context).width * 0.6; + + return Scaffold( + backgroundColor: context.colorTheme.whiteSoap, + appBar: DetailViewAppBar( + actions: const [ + SksUserDataButton(), + ], + ), + body: Center( + child: Column( + children: [ + const Spacer(), + SizedBox.square( + dimension: animationSize, + child: Lottie.asset( + Assets.animations.sksClosed, + fit: BoxFit.cover, + repeat: false, + frameRate: const FrameRate(LottieAnimationConfig.frameRate), + renderCache: RenderCache.drawingCommands, + onLoaded: (composition) { + final totalDuration = composition.duration; + Future.delayed( + totalDuration * + 0.8, // in my opinion the animation is a bit boring at the end, so we can show the texts a bit earlier + () { + isAnimationCompleted.value = true; + }); + }, + ), + ), + Opacity( + opacity: isAnimationCompleted.value ? 1 : 0, + child: Transform.translate( + offset: Offset( + 0, + -(animationSize * + 0.10), // the animation has some extra space at the bottom + ), + child: Column( + children: [ + Text( + context.localize.sks_menu_closed, + style: context.textTheme.headline.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (onShowLastMenuTap != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: MyTextButton( + actionTitle: context.localize.sks_show_last_menu, + onClick: onShowLastMenuTap, + showBorder: true, + color: context.colorTheme.blueAzure, + ), + ), + ], + ), + ), + ), + const Spacer(flex: 2), + ], + ), + ), + ); + } +} diff --git a/lib/features/sks-menu/presentation/widgets/sks_menu_header.dart b/lib/features/sks_menu/presentation/widgets/sks_menu_header.dart similarity index 97% rename from lib/features/sks-menu/presentation/widgets/sks_menu_header.dart rename to lib/features/sks_menu/presentation/widgets/sks_menu_header.dart index 537efc99..9e5af60f 100644 --- a/lib/features/sks-menu/presentation/widgets/sks_menu_header.dart +++ b/lib/features/sks_menu/presentation/widgets/sks_menu_header.dart @@ -1,4 +1,5 @@ import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; import "../../../../config/ui_config.dart"; import "../../../../theme/app_theme.dart"; diff --git a/lib/features/sks-menu/presentation/widgets/sks_menu_section.dart b/lib/features/sks_menu/presentation/widgets/sks_menu_section.dart similarity index 96% rename from lib/features/sks-menu/presentation/widgets/sks_menu_section.dart rename to lib/features/sks_menu/presentation/widgets/sks_menu_section.dart index f397a28b..6deed691 100644 --- a/lib/features/sks-menu/presentation/widgets/sks_menu_section.dart +++ b/lib/features/sks_menu/presentation/widgets/sks_menu_section.dart @@ -9,7 +9,7 @@ import "sks_menu_tiles.dart"; class SksMenuSection extends StatelessWidget { const SksMenuSection(this.data, {super.key}); - final List data; + final IList data; @override Widget build(BuildContext context) { diff --git a/lib/features/sks-menu/presentation/widgets/sks_menu_tiles.dart b/lib/features/sks_menu/presentation/widgets/sks_menu_tiles.dart similarity index 100% rename from lib/features/sks-menu/presentation/widgets/sks_menu_tiles.dart rename to lib/features/sks_menu/presentation/widgets/sks_menu_tiles.dart diff --git a/lib/features/sks_menu/presentation/widgets/sks_menu_view_loading.dart b/lib/features/sks_menu/presentation/widgets/sks_menu_view_loading.dart new file mode 100644 index 00000000..41d34ee1 --- /dev/null +++ b/lib/features/sks_menu/presentation/widgets/sks_menu_view_loading.dart @@ -0,0 +1,96 @@ +import "package:flutter/material.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../widgets/loading_widgets/scrolable_loader_builder.dart"; +import "../../../../widgets/loading_widgets/shimmer_loading.dart"; + +class SksMenuViewLoading extends StatelessWidget { + const SksMenuViewLoading({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const Shimmer( + linearGradient: shimmerGradient, + child: Column( + children: [ + _SksMenuHeaderLoading(), + Expanded( + child: _SksMenuTilesLoading(), + ), + ], + ), + ); + } +} + +class _SksMenuTilesLoading extends StatelessWidget { + const _SksMenuTilesLoading(); + static const groupElements = 3; + @override + Widget build(BuildContext context) { + return ScrollableLoaderBuilder( + itemsSpacing: 4, + mainAxisItemSize: 14, + scrollDirection: Axis.vertical, + itemBuilder: (BuildContext context, int index) { + return Padding( + padding: + const EdgeInsets.symmetric(vertical: SksMenuConfig.paddingMedium), + child: ShimmerLoadingItem( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, _) { + return const _LoadingTitle(); + }, + separatorBuilder: (context, _) => const SizedBox(), + itemCount: groupElements, + ), + ), + ); + }, + ); + } +} + +class _SksMenuHeaderLoading extends StatelessWidget { + const _SksMenuHeaderLoading(); + @override + Widget build(BuildContext context) { + return ShimmerLoadingItem( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(SksMenuConfig.borderRadius), + ), + width: double.infinity, + height: 250, + ), + ); + } +} + +class _LoadingTitle extends StatelessWidget { + const _LoadingTitle(); + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + SksMenuConfig.paddingLarge, + 0, + SksMenuConfig.paddingLarge, + SksMenuConfig.paddingMedium, + ), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(SksMenuConfig.borderRadius), + ), + width: double.infinity, + height: 50, + ), + ); + } +} diff --git a/lib/features/sks_menu/presentation/widgets/technical_message.dart b/lib/features/sks_menu/presentation/widgets/technical_message.dart new file mode 100644 index 00000000..90727919 --- /dev/null +++ b/lib/features/sks_menu/presentation/widgets/technical_message.dart @@ -0,0 +1,46 @@ +import "package:flutter/material.dart"; + +import "../../../../config/ui_config.dart"; +import "../../../../theme/app_theme.dart"; +import "../../data/models/dish_category_enum.dart"; + +enum AlertType { info, error } + +class TechnicalMessage extends StatelessWidget { + const TechnicalMessage({ + super.key, + required this.message, + this.title, + this.alertType = AlertType.error, + }); + final String message; + final String? title; + final AlertType alertType; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all( + HomeViewConfig.paddingMedium, + ), + child: ClipRRect( + borderRadius: + BorderRadius.circular(AppWidgetsConfig.borderRadiusMedium), + child: ColoredBox( + color: alertType == AlertType.error + ? context.colorTheme.orangePomegranade + : context.colorTheme.blueAzure, + child: ListTile( + title: Text( + title ?? DishCategory.technicalInfo.getLocalizedName(context), + style: context.textTheme.titleWhite, + ), + subtitle: Text( + message, + style: context.textTheme.bodyWhite, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/sks_people_live/presentation/widgets/sks_user_data_button.dart b/lib/features/sks_people_live/presentation/widgets/sks_user_data_button.dart index 6758ac7f..0e04af30 100644 --- a/lib/features/sks_people_live/presentation/widgets/sks_user_data_button.dart +++ b/lib/features/sks_people_live/presentation/widgets/sks_user_data_button.dart @@ -3,6 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "../../../../config/ui_config.dart"; import "../../../../theme/app_theme.dart"; +import "../../../sks_chart/presentation/sks_chart_sheet.dart"; import "../../data/models/sks_user_data.dart"; import "../../data/repository/latest_sks_user_data_repo.dart"; @@ -14,7 +15,18 @@ class SksUserDataButton extends ConsumerWidget { final asyncSksUserData = ref.watch(getLatestSksUserDataProvider); return asyncSksUserData.when( - data: _SksButton.new, + data: (sksUsersData) => _SksButton( + sksUsersData, + onTap: () async => showModalBottomSheet( + context: context, + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * + FilterConfig.bottomSheetHeightFactor, + ), + isScrollControlled: true, + builder: (BuildContext context) => const SksChartSheet(), + ), + ), error: (error, stackTrace) => const SizedBox.shrink(), loading: () => const SizedBox.shrink(), ); @@ -22,8 +34,9 @@ class SksUserDataButton extends ConsumerWidget { } class _SksButton extends StatelessWidget { - const _SksButton(this.sksUserData, {super.key}); + const _SksButton(this.sksUserData, {required this.onTap}); + final VoidCallback onTap; final SksUserData sksUserData; @override @@ -31,7 +44,7 @@ class _SksButton extends StatelessWidget { return Padding( padding: SksConfig.outerPadding, child: GestureDetector( - onTap: () {}, + onTap: onTap, child: Row( children: [ Container( @@ -73,9 +86,9 @@ extension TrendIcon on Trend { Icon get icon { switch (this) { case Trend.increasing: - return const Icon(Icons.trending_up, color: Color(0xFF28a745)); + return const Icon(Icons.trending_up, color: Colors.grey); case Trend.decreasing: - return const Icon(Icons.trending_down, color: Color(0xFFdc3545)); + return const Icon(Icons.trending_down, color: Colors.grey); case Trend.stable: return const Icon(Icons.trending_flat, color: Colors.grey); } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0335b6cc..265eaf43 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -105,6 +105,9 @@ "student_councils": "Samorząd", "hi_student": "Hej studencie!", "guide_development_info": "Pamiętaj, że przewodnik jest wciąż w fazie rozwoju :)", + "guide_ideas_info": "Masz ciekawy pomysł? Podziel się z nami!", + "guide_click_here": "Zgłoś swój pomysł", + "guide_subject_default_content": "Pomsył na rozwój ToPWR", "streak_counter": "Używasz ToPWR nieprzerwanie od {days, plural, =1{1 dnia} other{{days} dni}}🔥", "@streak_counter": { "description": "A message with a single parameter", @@ -147,8 +150,65 @@ "settings": "Ustawienia", "about_the_app": "O aplikacji", "other_view" : "Inne", + "sks_chart_title" : "Przybliżona liczba osób w Strefie Kultury Studenckiej", + "sks_chart_legend_users" : "Zmierzona liczba osób", + "sks_chart_legend_forecast" : "Prognozowana liczba osób", + "sks_chart_number_of_users" : "Liczba osób", + "map" : "Mapa", + "report_change_title" : "Coś się zmieniło?", + "report_change_button" : "Zgłoś zmianę", + "report_change_email" : "kn.solvro@pwr.edu.pl", + "report_change_subject" : "Sugestia zmiany - ToPWR", + "report_change_error_toast_message" : "Wystąpił błąd przy otwieraniu hiperłącza, sprawdź uprawnienia aplikacji", + "localization" : "Lokalizacja", + "amenities" : "Udogodnienia", + "surroundings": "Otoczenie", + "transport" : "Dojazd", + "entrances" : "Wejścia", + "elevators" : "Windy", + "toilets" : "Toilets", + "micro_navigation" : "Mikronawigacja", + "building_structure" : "Struktura budynku", + "room_information" : "Pomieszczenia", + "evacuation" : "Ewakuacja", + "storeys" : "{number, plural, =1{{number} piętro} few{{number} piętra} other{{number} pięter}}", + "@storeys" : { + "description" : "number of storeys with one parameter", + "placeholders": { + "number": { + "type": "int", + "example": "10" + } + } + }, + "assistance_dog" : "Do budynku i wszystkich jego pomieszczeń można wejść z psem asystującym i psem przewodnikiem", + "induction_loop": "W budynku jest/są pętle indukcyjne", + "micronavigation_system": "W budynku zostały zainstalowane urządzenia systemu nawigacyjno-informacyjnego", + "orientation_paths": "W budynku zastosowane zostały ścieżki naprowadzające (dotykowe)", + "information_boards_with_braille_description": "W budynku znajdują się czytelne tablice informacyjne zawierające opisy w alfabecie Braille'a", + "information_boards_with_large_font": "W budynku znajdują się czytelne tablice informacyjne zawierające napisy w dużej czcionce", + "sign_language_interpreter": "W budynku zapewniona jest możliwość skorzystania z usług tłumacza języka migowego", + "emergency_chairs": "W budynku zamieszczone zostały krzesła ewakuacyjne", + "parking_location" : "Miejsca parkingowe znajdują się {location}", + "digital_guide_website" : "www.przewodnik.pwr.edu.pl", "sks_old_menu": "Zobacz ostatnie menu", "sks_menu_closed" : "SKS Menu jest teraz niedostępne", + "sks_show_last_menu" : "Pokaż ostatnio dostępne menu", "confirm": "Zatwierdź", - "push_notifications_dialog_info": "Obecnie nie korzystamy z powiadomień push, ale planujemy dodać je w przyszłości. Możesz wyrazić na nie zgodę już teraz." -} \ No newline at end of file + "push_notifications_dialog_info": "Obecnie nie korzystamy z powiadomień push, ale planujemy dodać je w przyszłości. Możesz wyrazić na nie zgodę już teraz.", + "sks_menu_technical_info": "KOMUNIKAT", + "sks_note": "UWAGA", + "sks_menu_you_see_last_menu": "Aktualne menu SKS jest niedostępne. Przeglądasz ostatnio dostępną wersję.", + "my_offline_error_message": "Wystąpił błąd podczas pobierania danych dotyczących {data_type}", + "@my_offline_error_message": { + "description": "An error message with a single parameter", + "placeholders": { + "data_type": { + "type": "String", + "example": "wydziałów" + } + } + }, + "measured_number_of_users": "Zmierzona liczba użytkowników", + "forecasted_number_of_users": "Prognozowana liczba użytkowników" +} diff --git a/lib/shared_api_clients/sks_api_client.dart b/lib/shared_api_clients/sks_api_client.dart new file mode 100644 index 00000000..96579d64 --- /dev/null +++ b/lib/shared_api_clients/sks_api_client.dart @@ -0,0 +1,16 @@ +import "package:dio/dio.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:riverpod_annotation/riverpod_annotation.dart"; + +import "../config/env.dart"; + +part "sks_api_client.g.dart"; + +@riverpod +Dio sksClient(Ref ref) { + return Dio( + BaseOptions( + baseUrl: Env.sksUrl, + ), + ); +} diff --git a/lib/utils/datetime_utils.dart b/lib/utils/datetime_utils.dart index 88588e93..18b1f035 100644 --- a/lib/utils/datetime_utils.dart +++ b/lib/utils/datetime_utils.dart @@ -18,6 +18,23 @@ extension DateTimeUtilsX on DateTime { return "$capitalizedDay, $date"; } + String toDayDateHourString() { + final DateFormat dayFormat = DateFormat("EEEE", "pl_PL"); + final DateFormat dateFormat = DateFormat("dd.MM.yyyy"); + final DateFormat hourFormat = DateFormat("HH:mm"); + final String day = dayFormat.format(this); + final String capitalizedDay = + day[0].toUpperCase() + day.substring(1).toLowerCase(); + final String date = dateFormat.format(this); + final String hour = hourFormat.format(this); + return "$capitalizedDay, $date $hour"; + } + + String toHourMinuteString() { + final DateFormat hourFormat = DateFormat("HH:mm"); + return hourFormat.format(this); + } + // Convert DateTime to Date (remove time) DateTime get date => DateTime(year, month, day); diff --git a/lib/utils/determine_contact_icon.dart b/lib/utils/determine_contact_icon.dart index 5e7c9807..c948a412 100644 --- a/lib/utils/determine_contact_icon.dart +++ b/lib/utils/determine_contact_icon.dart @@ -13,7 +13,8 @@ class ContactIconsModel { ContactIconsModel({ String? text, this.url, - }) : icon = url.determineIcon(), + String? icon, + }) : icon = icon ?? url.determineIcon(), order = url.determineIconOrder(), text = text ?? url; } diff --git a/lib/utils/launch_url_util.dart b/lib/utils/launch_url_util.dart index 636f47fe..0b3fc6ea 100644 --- a/lib/utils/launch_url_util.dart +++ b/lib/utils/launch_url_util.dart @@ -1,10 +1,13 @@ import "dart:async"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:fluttertoast/fluttertoast.dart"; import "package:url_launcher/url_launcher.dart"; import "../config/url_config.dart"; import "../features/navigator/utils/navigation_commands.dart"; +import "../theme/colors.dart"; +import "context_extensions.dart"; extension LaunchUrlUtilX on WidgetRef? { Future launch(String uriStr) async { @@ -19,6 +22,20 @@ extension LaunchUrlUtilX on WidgetRef? { if (await canLaunchUrl(uri)) { return launchUrl(uri); } + + final String toastMsg = + this?.context.localize.report_change_error_toast_message ?? ""; + + if (toastMsg.isNotEmpty) { + await Fluttertoast.showToast( + msg: toastMsg, + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.BOTTOM, + backgroundColor: ColorsConsts.greyLight, + textColor: ColorsConsts.blackMirage, + ); + } + return false; } } diff --git a/lib/widgets/chart_elements.dart b/lib/widgets/chart_elements.dart new file mode 100644 index 00000000..b4bd09af --- /dev/null +++ b/lib/widgets/chart_elements.dart @@ -0,0 +1,5 @@ +import "package:fl_chart/fl_chart.dart"; + +class HideLabels extends AxisTitles { + const HideLabels() : super(sideTitles: const SideTitles()); +} diff --git a/lib/widgets/detail_views/contact_section.dart b/lib/widgets/detail_views/contact_section.dart index 24827dc0..d8b8c639 100644 --- a/lib/widgets/detail_views/contact_section.dart +++ b/lib/widgets/detail_views/contact_section.dart @@ -1,6 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:flutter/cupertino.dart"; import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "../../theme/app_theme.dart"; @@ -9,10 +9,10 @@ import "../../utils/launch_url_util.dart"; import "contact_icon_widget.dart"; class ContactSection extends StatelessWidget { - const ContactSection({super.key, required this.list, required this.title}); + const ContactSection({super.key, required this.list, this.title}); final IList list; - final String title; + final String? title; @override Widget build(BuildContext context) { @@ -23,8 +23,10 @@ class ContactSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: context.textTheme.headline), - const SizedBox(height: 16), + if (title != null) ...[ + Text(title!, style: context.textTheme.headline), + const SizedBox(height: 16), + ], for (final item in sorted) Padding( padding: const EdgeInsets.only(bottom: 16), @@ -59,11 +61,10 @@ class _ContactIcon extends ConsumerWidget { const SizedBox(width: 16), Expanded( child: RichText( - overflow: TextOverflow.ellipsis, - maxLines: 2, text: TextSpan( text: text, style: context.textTheme.bodyOrange.copyWith( + color: url.isNotEmpty ? null : Colors.black, decoration: url.isNotEmpty ? TextDecoration.underline : TextDecoration.none, diff --git a/lib/widgets/my_error_widget.dart b/lib/widgets/my_error_widget.dart index ff33a81a..0ee7a628 100644 --- a/lib/widgets/my_error_widget.dart +++ b/lib/widgets/my_error_widget.dart @@ -5,7 +5,9 @@ import "package:logger/logger.dart"; import "package:lottie/lottie.dart"; import "../api_base/query_adapter.dart"; +import "../api_base_rest/client/offline_error.dart"; import "../config/ui_config.dart"; +import "../features/offline_messages/widgets/general_offline_message.dart"; import "../features/offline_messages/widgets/grapgql_offline_message.dart"; import "../features/parkings_view/api_client/iparking_commands.dart"; import "../features/parkings_view/widgets/offline_parkings_view.dart"; @@ -32,6 +34,11 @@ class MyErrorWidget extends HookWidget { return switch (error) { ParkingsOfflineException() => const OfflineParkingsView(), GqlOfflineException(:final ttlKey) => OfflineGraphQLMessage(ttlKey), + RestFrameworkOfflineException(:final localizedMessage, :final onRetry) => + OfflineMessage( + localizedMessage(context), + onRefresh: onRetry, + ), _ => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/widgets/my_text_button.dart b/lib/widgets/my_text_button.dart index cca32eba..812e3404 100644 --- a/lib/widgets/my_text_button.dart +++ b/lib/widgets/my_text_button.dart @@ -7,25 +7,35 @@ class MyTextButton extends StatelessWidget { super.key, this.onClick, required this.actionTitle, + this.showBorder = false, + this.color, }); final VoidCallback? onClick; final String actionTitle; - + final bool showBorder; + final Color? color; @override Widget build(BuildContext context) { return TextButton( onPressed: onClick, style: TextButton.styleFrom( padding: const EdgeInsets.all(12), + side: showBorder + ? BorderSide( + color: color ?? context.colorTheme.orangePomegranade, + ) + : null, ), child: Text( actionTitle, style: onClick == null ? context.textTheme.boldBodyOrange.copyWith( - color: context.colorTheme.greyPigeon, + color: color ?? context.colorTheme.greyPigeon, ) - : context.textTheme.boldBodyOrange, + : context.textTheme.boldBodyOrange.copyWith( + color: color ?? context.colorTheme.orangePomegranade, + ), ), ); } diff --git a/lib/widgets/text_and_url_widget.dart b/lib/widgets/text_and_url_widget.dart new file mode 100644 index 00000000..babe388d --- /dev/null +++ b/lib/widgets/text_and_url_widget.dart @@ -0,0 +1,46 @@ +import "package:flutter/cupertino.dart"; +import "package:flutter/gestures.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../theme/app_theme.dart"; +import "../utils/launch_url_util.dart"; + +class TextAndUrl extends ConsumerWidget { + const TextAndUrl( + this.url, + this.text, { + super.key, + }); + + final String url; + final String text; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text.rich( + TextSpan( + text: text, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + children: [ + TextSpan( + text: url.replaceFirst("https://", " www."), + style: context.textTheme.bodyOrange.copyWith( + decoration: TextDecoration.underline, + decorationColor: context.colorTheme.orangePomegranade, + fontWeight: FontWeight.bold, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async => ref.launch(url), + ), + ], + ), + textAlign: TextAlign.center, + style: context.textTheme.body, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a38932a5..90bf3ec6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,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: 1.1.5+60 +version: 1.1.6+61 environment: sdk: ^3.5.3 @@ -51,7 +51,7 @@ dependencies: freezed_annotation: ^2.4.4 json_annotation: ^4.9.0 collection: ^1.19.1 - fl_chart: ^0.69.0 + fl_chart: ^0.69.2 permission_handler: ^11.3.1 flutter_widget_from_html_core: ^0.15.2 html: ^0.15.4 @@ -89,6 +89,8 @@ dependencies: upgrader: ^11.3.0 in_app_review: ^2.0.9 flutter_map_animations: ^0.7.1 + fluttertoast: ^8.2.8 + dotted_border: ^2.1.0 dev_dependencies: flutter_test: @@ -145,6 +147,15 @@ flutter: - assets/svg/contact_icons/x.svg - assets/svg/contact_icons/tiktok.svg - assets/svg/contact_icons/discord.svg + - assets/svg/digital_guide/storey.svg + - assets/svg/digital_guide/assistance_dog.svg + - assets/svg/digital_guide/braille.svg + - assets/svg/digital_guide/emergency_chairs.svg + - assets/svg/digital_guide/induction_loop.svg + - assets/svg/digital_guide/large_font.svg + - assets/svg/digital_guide/micronavigation.svg + - assets/svg/digital_guide/orientation_paths.svg + - assets/svg/digital_guide/sign_language.svg - assets/animations/error.json - assets/animations/search.json - assets/animations/offline.json