diff --git a/ios/Podfile b/ios/Podfile index 3e32eec2..c4f7a36b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -37,6 +37,64 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |build_configuration| + # GoogleSignIn does not support arm64 simulators. + # https://github.com/flutter/flutter/issues/85713 + build_configuration.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64 i386' + + # You can enable the permissions needed here. For example to enable camera + # permission, just remove the `#` character in front so it looks like this: + # + # ## dart: PermissionGroup.camera + # 'PERMISSION_CAMERA=1' + # + # Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler/ios/Classes/PermissionHandlerEnums.h + build_configuration.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + ## dart: PermissionGroup.calendar + # 'PERMISSION_EVENTS=1', + + ## dart: PermissionGroup.reminders + # 'PERMISSION_REMINDERS=1', + + ## dart: PermissionGroup.contacts + # 'PERMISSION_CONTACTS=1', + + ## dart: PermissionGroup.camera + # 'PERMISSION_CAMERA=1', + + ## dart: PermissionGroup.microphone + # 'PERMISSION_MICROPHONE=1', + + ## dart: PermissionGroup.speech + # 'PERMISSION_SPEECH_RECOGNIZER=1', + + ## dart: PermissionGroup.photos + # 'PERMISSION_PHOTOS=1', + + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + # 'PERMISSION_LOCATION=1', + + ## dart: PermissionGroup.notification + 'PERMISSION_NOTIFICATIONS=1', + + ## dart: PermissionGroup.mediaLibrary + # 'PERMISSION_MEDIA_LIBRARY=1', + + ## dart: PermissionGroup.sensors + # 'PERMISSION_SENSORS=1', + + ## dart: PermissionGroup.bluetooth + # 'PERMISSION_BLUETOOTH=1', + + ## dart: PermissionGroup.appTrackingTransparency + # 'PERMISSION_APP_TRACKING_TRANSPARENCY=1', + + ## dart: PermissionGroup.criticalAlerts + # 'PERMISSION_CRITICAL_ALERTS=1' + ] + end end end $FirebaseAnalyticsWithoutAdIdSupport = true diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist new file mode 100644 index 00000000..a1232f0d --- /dev/null +++ b/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,34 @@ + + + + + CLIENT_ID + 510978291920-31sgk97k4bifhc0ebpamk9m46om2e50r.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.510978291920-31sgk97k4bifhc0ebpamk9m46om2e50r + API_KEY + AIzaSyAYZ5JlWF94jBGrcds7fSi5uMN1zmuieec + GCM_SENDER_ID + 510978291920 + PLIST_VERSION + 1 + BUNDLE_ID + mirea.ninja.mireaapp + PROJECT_ID + rtu-mirea-app + STORAGE_BUCKET + rtu-mirea-app.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:510978291920:ios:dd9496a1680c72828c46d5 + + \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 69abe59a..24a33c71 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + FLTEnableImpeller + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -61,5 +63,9 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + + PermissionGroupNotification + Приложение запрашивает разрешение на отправку уведомлений diff --git a/lib/common/utils/schedule_utils.dart b/lib/common/utils/schedule_utils.dart index eff2a8b4..690c8e93 100644 --- a/lib/common/utils/schedule_utils.dart +++ b/lib/common/utils/schedule_utils.dart @@ -21,9 +21,9 @@ class ScheduleUtils { "14:20": 4, "16:20": 5, "18:00": 6, - "19:40": 7, "18:30": 7, - "20:10": 8, + "19:40": 8, + "20:10": 9, }; static Map get universityTimesEnd => const { @@ -33,9 +33,9 @@ class ScheduleUtils { "15:50": 4, "17:50": 5, "19:30": 6, - "21:00": 7, "20:00": 7, - "21:40": 8, + "21:00": 8, + "21:40": 9, }; static bool isCollegeGroup(String group) { diff --git a/lib/main.dart b/lib/main.dart index edaaa3a8..b58323e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'dart:io' show Platform; -import 'package:auto_route/auto_route.dart'; import 'package:dio/dio.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/foundation.dart'; @@ -7,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:rtu_mirea_app/common/oauth.dart'; @@ -22,13 +22,13 @@ import 'package:rtu_mirea_app/presentation/bloc/map_cubit/map_cubit.dart'; import 'package:rtu_mirea_app/presentation/bloc/news_bloc/news_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/nfc_feedback_bloc/nfc_feedback_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart'; +import 'package:rtu_mirea_app/presentation/bloc/notification_preferences/notification_preferences_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/schedule_bloc/schedule_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/scores_bloc/scores_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/stories_bloc/stories_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/update_info_bloc/update_info_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:intl/intl_standalone.dart'; import 'package:rtu_mirea_app/service_locator.dart' as dependency_injection; @@ -60,14 +60,14 @@ class GlobalBlocObserver extends BlocObserver { Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await dependency_injection.setup(); - - WidgetDataProvider.initData(); - await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); + await dependency_injection.setup(); + + WidgetDataProvider.initData(); + if (Platform.isAndroid || Platform.isIOS) { await FirebaseAnalytics.instance.logAppOpen(); } @@ -137,10 +137,10 @@ Future main() async { class App extends StatelessWidget { const App({Key? key}) : super(key: key); - static final appRouter = AppRouter(); - @override Widget build(BuildContext context) { + final router = getIt(); + // blocking the orientation of the application to // vertical only SystemChrome.setPreferredOrientations([ @@ -182,6 +182,9 @@ class App extends StatelessWidget { BlocProvider( create: (_) => getIt(), ), + BlocProvider( + create: (_) => getIt(), + ), ], child: Consumer( builder: (BuildContext context, AppNotifier value, Widget? child) { @@ -198,19 +201,7 @@ class App extends StatelessWidget { locale: const Locale('ru'), debugShowCheckedModeBanner: false, title: 'Приложение РТУ МИРЭА', - routerDelegate: appRouter.delegate( - navigatorObservers: () => [ - FirebaseAnalyticsObserver( - analytics: FirebaseAnalytics.instance, - ), - AutoRouteObserver(), - SentryNavigatorObserver( - autoFinishAfter: const Duration(seconds: 5), - setRouteNameAsTransaction: true), - ], - ), - routeInformationProvider: appRouter.routeInfoProvider(), - routeInformationParser: appRouter.defaultRouteParser(), + routerConfig: router, themeMode: AppTheme.themeMode, theme: AppTheme.theme, darkTheme: AppTheme.darkTheme, diff --git a/lib/presentation/bloc/notification_preferences/notification_preferences_bloc.dart b/lib/presentation/bloc/notification_preferences/notification_preferences_bloc.dart new file mode 100644 index 00000000..29b36ea7 --- /dev/null +++ b/lib/presentation/bloc/notification_preferences/notification_preferences_bloc.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:notifications_repository/notifications_repository.dart'; + +part 'notification_preferences_state.dart'; +part 'notification_preferences_event.dart'; + +enum Category { + /// Общеуниверситетские объявления. + announcements, + + /// Уведомления, связанные с изменениями в расписании. Привязаны к группе. + scheduleUpdates, + + /// Уведомления для конкретной группы. Обязательная категория, которую + /// пользователь не может отключить. + group, +} + +const visibleCategoryNames = { + Category.announcements: 'Объявления', + Category.scheduleUpdates: 'Обновления расписания', +}; + +/// Транслитерирует название группы для использования в качестве названия +/// категории уведомлений. +String transletirateGroupName(String groupName) { + final mappings = { + 'А': 'A', + 'Б': 'B', + 'В': 'V', + 'Г': 'G', + 'Д': 'D', + 'Е': 'E', + 'Ё': 'E', + 'Ж': 'Zh', + 'З': 'Z', + 'И': 'I', + 'Й': 'I', + 'К': 'K', + 'Л': 'L', + 'М': 'M', + 'Н': 'N', + 'О': 'O', + 'П': 'P', + 'Р': 'R', + 'С': 'S', + 'Т': 'T', + 'У': 'U', + 'Ф': 'F', + 'Х': 'H', + 'Ц': 'Ts', + 'Ч': 'Ch', + 'Ш': 'Sh', + 'Щ': 'Sch', + 'Ъ': '', + 'Ы': 'Y', + 'Ь': '', + 'Э': 'E', + 'Ю': 'Ju', + 'Я': 'Ja', + }; + + return groupName + .split('-') + .map((word) => + word.split('').map((char) => mappings[char] ?? char).join('')) + .join('-'); +} + +/// Категория уведомлений. [toString] возвращает название категории, которое +/// используется при подписке на уведомления. [fromString] возвращает объект +/// [Topic] из названия категории. +class Topic extends Equatable { + Topic({ + required this.topic, + String? groupName, + }) { + if (topic == Category.group || topic == Category.scheduleUpdates) { + assert(groupName != null); + + this.groupName = transletirateGroupName(groupName ?? ''); + } else { + this.groupName = null; + } + } + + final Category topic; + late final String? groupName; + + @override + String toString() { + switch (topic) { + case Category.announcements: + return 'Announcements'; + case Category.scheduleUpdates: + return 'ScheduleUpdates__${groupName!}'; + case Category.group: + return groupName!; + } + } + + String getVisibleName() { + switch (topic) { + case Category.announcements: + return visibleCategoryNames[Category.announcements]!; + case Category.scheduleUpdates: + return visibleCategoryNames[Category.scheduleUpdates]!; + case Category.group: + return groupName!; + } + } + + static Topic fromVisibleName(String name, String groupName) { + switch (name) { + case 'Объявления': + return Topic(topic: Category.announcements); + case 'Обновления расписания': + return Topic( + topic: Category.scheduleUpdates, + groupName: groupName, + ); + default: + return Topic( + topic: Category.group, + groupName: name, + ); + } + } + + static Topic fromString(String category) { + final categoryParts = category.split('__'); + final topic = categoryParts[0]; + + switch (topic) { + case 'Announcements': + return Topic(topic: Category.announcements); + case 'ScheduleUpdates': + return Topic( + topic: Category.scheduleUpdates, + groupName: categoryParts[1], + ); + default: + return Topic( + topic: Category.group, + groupName: category, + ); + } + } + + @override + List get props => [topic, groupName]; +} + +class NotificationPreferencesBloc + extends Bloc { + NotificationPreferencesBloc({ + required NotificationsRepository notificationsRepository, + }) : _notificationsRepository = notificationsRepository, + super( + NotificationPreferencesState.initial(), + ) { + on( + _onCategoriesPreferenceToggled, + ); + on( + _onInitialCategoriesPreferencesRequested, + ); + } + + final NotificationsRepository _notificationsRepository; + + FutureOr _onCategoriesPreferenceToggled( + CategoriesPreferenceToggled event, + Emitter emit, + ) async { + emit(state.copyWith(status: NotificationPreferencesStatus.loading)); + + final updatedCategories = Set.from(state.selectedCategories); + + updatedCategories.contains(event.category) + ? updatedCategories.remove(event.category) + : updatedCategories.add(event.category); + + try { + final categoriesToSubscribe = updatedCategories + .map((category) => Topic.fromVisibleName(category, event.group)) + .toSet(); + + /// Добавляем в категории названия академической группы для подписки на + /// уведомления для группы. Это обязательная категория, которую + /// пользователь не может отключить. + categoriesToSubscribe.add( + Topic(topic: Category.group, groupName: event.group), + ); + + /// Убираем те категории, название которых содержит название группы, но + /// не совпадает с названием группы в [event.group]. + categoriesToSubscribe.removeWhere((category) { + if (category.groupName == null) { + return false; + } + + final groupName = category.groupName!.toLowerCase(); + final eventGroupName = + transletirateGroupName(event.group).toLowerCase(); + + return groupName != eventGroupName; + }); + + emit( + state.copyWith( + status: NotificationPreferencesStatus.success, + selectedCategories: updatedCategories, + ), + ); + + await _notificationsRepository.setCategoriesPreferences( + categoriesToSubscribe.map((e) => e.toString()).toSet()); + } catch (error, stackTrace) { + emit( + state.copyWith(status: NotificationPreferencesStatus.failure), + ); + addError(error, stackTrace); + } + } + + FutureOr _onInitialCategoriesPreferencesRequested( + InitialCategoriesPreferencesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: NotificationPreferencesStatus.loading)); + + try { + Set selectedCategories = await _notificationsRepository + .fetchCategoriesPreferences() + .then((categories) => + categories?.map((e) => Topic.fromString(e)).toSet() ?? {}); + + /// Подписываемся на уведомления для группы [event.group] и отписываемся + /// от уведомлений для других групп. + if (!selectedCategories.contains( + Topic(topic: Category.group, groupName: event.group), + )) { + selectedCategories = selectedCategories + .where((category) => category.topic != Category.group) + .toSet(); + + selectedCategories.add( + Topic(topic: Category.group, groupName: event.group), + ); + + await _notificationsRepository.setCategoriesPreferences( + selectedCategories.map((e) => e.toString()).toSet()); + } + + await _notificationsRepository.toggleNotifications( + enable: selectedCategories.isNotEmpty, + ); + + emit( + state.copyWith( + status: NotificationPreferencesStatus.success, + selectedCategories: + selectedCategories.map((e) => e.getVisibleName()).toSet(), + categories: visibleCategoryNames.values.toSet(), + ), + ); + } catch (error, stackTrace) { + emit( + state.copyWith(status: NotificationPreferencesStatus.failure), + ); + addError(error, stackTrace); + } + } +} diff --git a/lib/presentation/bloc/notification_preferences/notification_preferences_event.dart b/lib/presentation/bloc/notification_preferences/notification_preferences_event.dart new file mode 100644 index 00000000..fa6f72cf --- /dev/null +++ b/lib/presentation/bloc/notification_preferences/notification_preferences_event.dart @@ -0,0 +1,29 @@ +part of 'notification_preferences_bloc.dart'; + +abstract class NotificationPreferencesEvent extends Equatable { + const NotificationPreferencesEvent(); +} + +class CategoriesPreferenceToggled extends NotificationPreferencesEvent { + const CategoriesPreferenceToggled( + {required this.category, required this.group}); + + final String category; + + /// Название академической группы. + final String group; + + @override + List get props => [category, group]; +} + +class InitialCategoriesPreferencesRequested + extends NotificationPreferencesEvent { + const InitialCategoriesPreferencesRequested({required this.group}); + + /// Название академической группы. + final String group; + + @override + List get props => [group]; +} diff --git a/lib/presentation/bloc/notification_preferences/notification_preferences_state.dart b/lib/presentation/bloc/notification_preferences/notification_preferences_state.dart new file mode 100644 index 00000000..bf3876d1 --- /dev/null +++ b/lib/presentation/bloc/notification_preferences/notification_preferences_state.dart @@ -0,0 +1,42 @@ +part of 'notification_preferences_bloc.dart'; + +enum NotificationPreferencesStatus { + initial, + loading, + success, + failure, +} + +class NotificationPreferencesState extends Equatable { + const NotificationPreferencesState({ + required this.selectedCategories, + required this.status, + required this.categories, + }); + + NotificationPreferencesState.initial() + : this( + selectedCategories: {}, + status: NotificationPreferencesStatus.initial, + categories: {}, + ); + + final NotificationPreferencesStatus status; + final Set categories; + final Set selectedCategories; + + @override + List get props => [selectedCategories, status, categories]; + + NotificationPreferencesState copyWith({ + Set? selectedCategories, + NotificationPreferencesStatus? status, + Set? categories, + }) { + return NotificationPreferencesState( + selectedCategories: selectedCategories ?? this.selectedCategories, + status: status ?? this.status, + categories: categories ?? this.categories, + ); + } +} diff --git a/lib/presentation/bloc/user_bloc/user_bloc.dart b/lib/presentation/bloc/user_bloc/user_bloc.dart index f44829f6..0ab125b7 100644 --- a/lib/presentation/bloc/user_bloc/user_bloc.dart +++ b/lib/presentation/bloc/user_bloc/user_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:get/get.dart'; +import 'package:rtu_mirea_app/domain/entities/student.dart'; import 'package:rtu_mirea_app/domain/entities/user.dart'; import 'package:rtu_mirea_app/domain/usecases/get_auth_token.dart'; import 'package:rtu_mirea_app/domain/usecases/get_user_data.dart'; @@ -73,9 +74,7 @@ class UserBloc extends Bloc { (failure) => emit(const _Unauthorized()), (user) { FirebaseAnalytics.instance.logLogin(); - var student = user.students - .firstWhereOrNull((element) => element.status == 'активный'); - student ??= user.students.first; + var student = getActiveStudent(user); _setSentryUserIdentity( user.id.toString(), user.login, student.academicGroup); @@ -93,6 +92,13 @@ class UserBloc extends Bloc { res.fold((failure) => null, (r) => emit(const _Unauthorized())); } + static Student getActiveStudent(User user) { + var student = user.students + .firstWhereOrNull((element) => element.status == 'активный'); + student ??= user.students.first; + return student; + } + void _onGetUserDataEvent( UserEvent event, Emitter emit, @@ -111,9 +117,7 @@ class UserBloc extends Bloc { final user = await getUserData(); user.fold((failure) => emit(const _Unauthorized()), (r) { - var student = r.students - .firstWhereOrNull((element) => element.status == 'активный'); - student ??= r.students.first; + var student = getActiveStudent(r); _setSentryUserIdentity(r.id.toString(), r.login, student.academicGroup); emit(_LogInSuccess(r)); diff --git a/lib/presentation/core/routes/routes.dart b/lib/presentation/core/routes/routes.dart index f54f0e53..f2031c04 100644 --- a/lib/presentation/core/routes/routes.dart +++ b/lib/presentation/core/routes/routes.dart @@ -1,8 +1,13 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:auto_route/empty_router_widgets.dart'; -import 'package:dismissible_page/dismissible_page.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:rtu_mirea_app/domain/entities/news_item.dart'; +import 'package:rtu_mirea_app/domain/entities/story.dart'; +import 'package:rtu_mirea_app/domain/entities/user.dart'; import 'package:rtu_mirea_app/presentation/pages/home_page.dart'; +import 'package:rtu_mirea_app/presentation/pages/profile/notifications_settings_page.dart'; +import 'package:rtu_mirea_app/presentation/pages/scaffold_with_nav_bar.dart'; import 'package:rtu_mirea_app/presentation/pages/login/login_page.dart'; import 'package:rtu_mirea_app/presentation/pages/map/map_page.dart'; import 'package:rtu_mirea_app/presentation/pages/news/news_details_page.dart'; @@ -20,120 +25,149 @@ import 'package:rtu_mirea_app/presentation/pages/profile/profile_page.dart'; import 'package:rtu_mirea_app/presentation/pages/profile/profile_settings_page.dart'; import 'package:rtu_mirea_app/presentation/pages/schedule/groups_select_page.dart'; import 'package:rtu_mirea_app/presentation/pages/schedule/schedule_page.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; -@AdaptiveAutoRouter( - replaceInRouteName: 'Page,Route', - routes: [ - AutoRoute( - path: '/', - page: HomePage, - children: [ - AutoRoute( - path: 'schedule', - page: EmptyRouterPage, - name: 'ScheduleRouter', - initial: true, - children: [ - AutoRoute( - path: '', - page: SchedulePage, - ), - AutoRoute( - path: 'select-group', - page: GroupsSelectPage, - ), +GoRouter createRouter() => GoRouter( + initialLocation: '/home', + routes: [ + StatefulShellRoute.indexedStack( + builder: (BuildContext context, GoRouterState state, + StatefulNavigationShell navigationShell) { + return ScaffoldWithNavBar(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch(routes: [ + GoRoute( + path: '/news', + builder: (context, state) => NewsPage(), + routes: [ + GoRoute( + path: 'details', + builder: (context, state) => + NewsDetailsPage(newsItem: state.extra as NewsItem), + redirect: (context, state) { + if (state.extra == null) { + return '/news'; + } + return null; + }, + ), + ], + ), + ]), + StatefulShellBranch(routes: [ + GoRoute( + path: '/schedule', + builder: (context, state) => const SchedulePage(), + routes: [ + GoRoute( + path: 'story/:index', + pageBuilder: (context, state) => CustomTransitionPage( + fullscreenDialog: true, + opaque: false, + transitionsBuilder: (_, __, ___, child) => child, + child: StoriesWrapper( + stories: state.extra as List, + storyIndex: + int.parse(state.pathParameters['index'] ?? '0'), + ), + ), + redirect: (context, state) { + if (state.extra == null) { + return '/schedule'; + } + return null; + }, + ), + GoRoute( + path: 'select-group', + builder: (context, state) => const GroupsSelectPage(), + ), + ], + ), + ]), + StatefulShellBranch(routes: [ + GoRoute( + path: '/map', + builder: (context, state) => const MapPage(), + ), + ]), + StatefulShellBranch(routes: [ + GoRoute( + path: '/profile', + builder: (context, state) => const ProfilePage(), + routes: [ + GoRoute( + path: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: 'about', + builder: (context, state) => const AboutAppPage(), + ), + GoRoute( + path: 'announces', + builder: (context, state) => const ProfileAnnouncesPage(), + ), + GoRoute( + path: 'attendance', + builder: (context, state) => const ProfileAttendancePage(), + ), + GoRoute( + path: 'details', + builder: (context, state) => + ProfileDetailPage(user: state.extra as User), + redirect: (context, state) { + if (state.extra == null) { + return '/profile'; + } + return null; + }, + ), + GoRoute( + path: 'lectors', + builder: (context, state) => const ProfileLectrosPage(), + ), + GoRoute( + path: 'scores', + builder: (context, state) => const ProfileScoresPage(), + ), + GoRoute( + path: 'settings', + builder: (context, state) => const ProfileSettingsPage(), + routes: [ + GoRoute( + path: 'notifications', + builder: (context, state) => + const NotificationsSettingsPage(), + ), + ], + ), + GoRoute( + path: 'nfc-pass', + builder: (context, state) => const ProfileNfcPassPage(), + ) + ], + ), + ]), + StatefulShellBranch(routes: [ + GoRoute( + path: '/info', + builder: (context, state) => const AboutAppPage()), + ]), ], ), - AutoRoute( - path: 'news', - name: 'NewsRouter', - page: EmptyRouterPage, - children: [ - AutoRoute( - path: '', - page: NewsPage, - ), - AutoRoute( - path: 'details', - page: NewsDetailsPage, - ), - ], - ), - AutoRoute( - path: 'map', - page: MapPage, - ), - AutoRoute( - path: 'profile', - name: 'ProfileRouter', - page: EmptyRouterPage, - children: [ - AutoRoute( - path: '', - page: ProfilePage, - ), - AutoRoute( - path: 'login', - page: LoginPage, - ), - AutoRoute( - path: 'about', - page: AboutAppPage, - ), - AutoRoute( - path: 'announces', - page: ProfileAnnouncesPage, - ), - AutoRoute( - path: 'attendance', - page: ProfileAttendancePage, - ), - AutoRoute( - path: 'details', - page: ProfileDetailPage, - ), - AutoRoute( - path: 'lectors', - page: ProfileLectrosPage, - ), - AutoRoute( - path: 'scores', - page: ProfileScoresPage, - ), - AutoRoute( - path: 'settings', - page: ProfileSettingsPage, - ), - AutoRoute( - path: 'nfc-pass', - page: ProfileNfcPassPage, - ) - ], + GoRoute( + path: '/onboarding', + builder: (context, state) => const OnBoardingPage()), + GoRoute(path: '/home', builder: (context, state) => const HomePage()), + ], + observers: [ + FirebaseAnalyticsObserver( + analytics: FirebaseAnalytics.instance, ), - AutoRoute( - path: 'info', name: 'AboutAppDesktopRoute', page: AboutAppPage), + SentryNavigatorObserver( + autoFinishAfter: const Duration(seconds: 5), + setRouteNameAsTransaction: true), ], - ), - AutoRoute(path: '/onboarding', page: OnBoardingPage), - CustomRoute( - customRouteBuilder: transparentRoute, - opaque: false, - path: '/story', - name: 'StoriesWrapperRoute', - page: StoriesWrapper, - ), - RedirectRoute(path: '*', redirectTo: '/'), - ], -) -class $AppRouter {} - -Route transparentRoute( - BuildContext context, Widget child, CustomPage page) { - return TransparentRoute( - settings: page, - transitionDuration: const Duration(milliseconds: 250), - reverseTransitionDuration: const Duration(milliseconds: 250), - builder: (context) => child, - backgroundColor: Colors.black.withOpacity(0.45), - ); -} + ); diff --git a/lib/presentation/core/routes/routes.gr.dart b/lib/presentation/core/routes/routes.gr.dart deleted file mode 100644 index 2fea7ad1..00000000 --- a/lib/presentation/core/routes/routes.gr.dart +++ /dev/null @@ -1,680 +0,0 @@ -// ************************************************************************** -// AutoRouteGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND - -// ************************************************************************** -// AutoRouteGenerator -// ************************************************************************** -// -// ignore_for_file: type=lint - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i20; -import 'package:auto_route/empty_router_widgets.dart' as _i4; -import 'package:flutter/material.dart' as _i21; -import 'package:rtu_mirea_app/domain/entities/news_item.dart' as _i24; -import 'package:rtu_mirea_app/domain/entities/story.dart' as _i23; -import 'package:rtu_mirea_app/domain/entities/user.dart' as _i25; -import 'package:rtu_mirea_app/presentation/core/routes/routes.dart' as _i22; -import 'package:rtu_mirea_app/presentation/pages/home_page.dart' as _i1; -import 'package:rtu_mirea_app/presentation/pages/login/login_page.dart' as _i12; -import 'package:rtu_mirea_app/presentation/pages/map/map_page.dart' as _i5; -import 'package:rtu_mirea_app/presentation/pages/news/news_details_page.dart' - as _i10; -import 'package:rtu_mirea_app/presentation/pages/news/news_page.dart' as _i9; -import 'package:rtu_mirea_app/presentation/pages/onboarding/onboarding_page.dart' - as _i2; -import 'package:rtu_mirea_app/presentation/pages/profile/about_app_page.dart' - as _i6; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_announces_page.dart' - as _i13; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_attendance_page.dart' - as _i14; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_detail_page.dart' - as _i15; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_lectors_page.dart' - as _i16; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_nfc_pass_page.dart' - as _i19; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_page.dart' - as _i11; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_scores_page.dart' - as _i17; -import 'package:rtu_mirea_app/presentation/pages/profile/profile_settings_page.dart' - as _i18; -import 'package:rtu_mirea_app/presentation/pages/schedule/groups_select_page.dart' - as _i8; -import 'package:rtu_mirea_app/presentation/pages/schedule/schedule_page.dart' - as _i7; -import 'package:rtu_mirea_app/presentation/pages/schedule/widgets/stories_wrapper.dart' - as _i3; - -class AppRouter extends _i20.RootStackRouter { - AppRouter([_i21.GlobalKey<_i21.NavigatorState>? navigatorKey]) - : super(navigatorKey); - - @override - final Map pagesMap = { - HomeRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i1.HomePage(), - ); - }, - OnBoardingRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i2.OnBoardingPage(), - ); - }, - StoriesWrapperRoute.name: (routeData) { - final args = routeData.argsAs(); - return _i20.CustomPage( - routeData: routeData, - child: _i3.StoriesWrapper( - key: args.key, - stories: args.stories, - storyIndex: args.storyIndex, - ), - customRouteBuilder: _i22.transparentRoute, - opaque: false, - barrierDismissible: false, - ); - }, - ScheduleRouter.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i4.EmptyRouterPage(), - ); - }, - NewsRouter.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i4.EmptyRouterPage(), - ); - }, - MapRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i5.MapPage(), - ); - }, - ProfileRouter.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i4.EmptyRouterPage(), - ); - }, - AboutAppDesktopRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i6.AboutAppPage(), - ); - }, - ScheduleRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i7.SchedulePage(), - ); - }, - GroupsSelectRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i8.GroupsSelectPage(), - ); - }, - NewsRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i9.NewsPage(), - ); - }, - NewsDetailsRoute.name: (routeData) { - final args = routeData.argsAs(); - return _i20.AdaptivePage( - routeData: routeData, - child: _i10.NewsDetailsPage( - key: args.key, - newsItem: args.newsItem, - ), - ); - }, - ProfileRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i11.ProfilePage(), - ); - }, - LoginRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i12.LoginPage(), - ); - }, - AboutAppRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i6.AboutAppPage(), - ); - }, - ProfileAnnouncesRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i13.ProfileAnnouncesPage(), - ); - }, - ProfileAttendanceRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i14.ProfileAttendancePage(), - ); - }, - ProfileDetailRoute.name: (routeData) { - final args = routeData.argsAs(); - return _i20.AdaptivePage( - routeData: routeData, - child: _i15.ProfileDetailPage( - key: args.key, - user: args.user, - ), - ); - }, - ProfileLectrosRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i16.ProfileLectrosPage(), - ); - }, - ProfileScoresRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i17.ProfileScoresPage(), - ); - }, - ProfileSettingsRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i18.ProfileSettingsPage(), - ); - }, - ProfileNfcPassRoute.name: (routeData) { - return _i20.AdaptivePage( - routeData: routeData, - child: const _i19.ProfileNfcPassPage(), - ); - }, - }; - - @override - List<_i20.RouteConfig> get routes => [ - _i20.RouteConfig( - HomeRoute.name, - path: '/', - children: [ - _i20.RouteConfig( - '#redirect', - path: '', - parent: HomeRoute.name, - redirectTo: 'schedule', - fullMatch: true, - ), - _i20.RouteConfig( - ScheduleRouter.name, - path: 'schedule', - parent: HomeRoute.name, - children: [ - _i20.RouteConfig( - ScheduleRoute.name, - path: '', - parent: ScheduleRouter.name, - ), - _i20.RouteConfig( - GroupsSelectRoute.name, - path: 'select-group', - parent: ScheduleRouter.name, - ), - ], - ), - _i20.RouteConfig( - NewsRouter.name, - path: 'news', - parent: HomeRoute.name, - children: [ - _i20.RouteConfig( - NewsRoute.name, - path: '', - parent: NewsRouter.name, - ), - _i20.RouteConfig( - NewsDetailsRoute.name, - path: 'details', - parent: NewsRouter.name, - ), - ], - ), - _i20.RouteConfig( - MapRoute.name, - path: 'map', - parent: HomeRoute.name, - ), - _i20.RouteConfig( - ProfileRouter.name, - path: 'profile', - parent: HomeRoute.name, - children: [ - _i20.RouteConfig( - ProfileRoute.name, - path: '', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - LoginRoute.name, - path: 'login', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - AboutAppRoute.name, - path: 'about', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileAnnouncesRoute.name, - path: 'announces', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileAttendanceRoute.name, - path: 'attendance', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileDetailRoute.name, - path: 'details', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileLectrosRoute.name, - path: 'lectors', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileScoresRoute.name, - path: 'scores', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileSettingsRoute.name, - path: 'settings', - parent: ProfileRouter.name, - ), - _i20.RouteConfig( - ProfileNfcPassRoute.name, - path: 'nfc-pass', - parent: ProfileRouter.name, - ), - ], - ), - _i20.RouteConfig( - AboutAppDesktopRoute.name, - path: 'info', - parent: HomeRoute.name, - ), - ], - ), - _i20.RouteConfig( - OnBoardingRoute.name, - path: '/onboarding', - ), - _i20.RouteConfig( - StoriesWrapperRoute.name, - path: '/story', - ), - _i20.RouteConfig( - '*#redirect', - path: '*', - redirectTo: '/', - fullMatch: true, - ), - ]; -} - -/// generated route for -/// [_i1.HomePage] -class HomeRoute extends _i20.PageRouteInfo { - const HomeRoute({List<_i20.PageRouteInfo>? children}) - : super( - HomeRoute.name, - path: '/', - initialChildren: children, - ); - - static const String name = 'HomeRoute'; -} - -/// generated route for -/// [_i2.OnBoardingPage] -class OnBoardingRoute extends _i20.PageRouteInfo { - const OnBoardingRoute() - : super( - OnBoardingRoute.name, - path: '/onboarding', - ); - - static const String name = 'OnBoardingRoute'; -} - -/// generated route for -/// [_i3.StoriesWrapper] -class StoriesWrapperRoute extends _i20.PageRouteInfo { - StoriesWrapperRoute({ - _i21.Key? key, - required List<_i23.Story> stories, - required int storyIndex, - }) : super( - StoriesWrapperRoute.name, - path: '/story', - args: StoriesWrapperRouteArgs( - key: key, - stories: stories, - storyIndex: storyIndex, - ), - ); - - static const String name = 'StoriesWrapperRoute'; -} - -class StoriesWrapperRouteArgs { - const StoriesWrapperRouteArgs({ - this.key, - required this.stories, - required this.storyIndex, - }); - - final _i21.Key? key; - - final List<_i23.Story> stories; - - final int storyIndex; - - @override - String toString() { - return 'StoriesWrapperRouteArgs{key: $key, stories: $stories, storyIndex: $storyIndex}'; - } -} - -/// generated route for -/// [_i4.EmptyRouterPage] -class ScheduleRouter extends _i20.PageRouteInfo { - const ScheduleRouter({List<_i20.PageRouteInfo>? children}) - : super( - ScheduleRouter.name, - path: 'schedule', - initialChildren: children, - ); - - static const String name = 'ScheduleRouter'; -} - -/// generated route for -/// [_i4.EmptyRouterPage] -class NewsRouter extends _i20.PageRouteInfo { - const NewsRouter({List<_i20.PageRouteInfo>? children}) - : super( - NewsRouter.name, - path: 'news', - initialChildren: children, - ); - - static const String name = 'NewsRouter'; -} - -/// generated route for -/// [_i5.MapPage] -class MapRoute extends _i20.PageRouteInfo { - const MapRoute() - : super( - MapRoute.name, - path: 'map', - ); - - static const String name = 'MapRoute'; -} - -/// generated route for -/// [_i4.EmptyRouterPage] -class ProfileRouter extends _i20.PageRouteInfo { - const ProfileRouter({List<_i20.PageRouteInfo>? children}) - : super( - ProfileRouter.name, - path: 'profile', - initialChildren: children, - ); - - static const String name = 'ProfileRouter'; -} - -/// generated route for -/// [_i6.AboutAppPage] -class AboutAppDesktopRoute extends _i20.PageRouteInfo { - const AboutAppDesktopRoute() - : super( - AboutAppDesktopRoute.name, - path: 'info', - ); - - static const String name = 'AboutAppDesktopRoute'; -} - -/// generated route for -/// [_i7.SchedulePage] -class ScheduleRoute extends _i20.PageRouteInfo { - const ScheduleRoute() - : super( - ScheduleRoute.name, - path: '', - ); - - static const String name = 'ScheduleRoute'; -} - -/// generated route for -/// [_i8.GroupsSelectPage] -class GroupsSelectRoute extends _i20.PageRouteInfo { - const GroupsSelectRoute() - : super( - GroupsSelectRoute.name, - path: 'select-group', - ); - - static const String name = 'GroupsSelectRoute'; -} - -/// generated route for -/// [_i9.NewsPage] -class NewsRoute extends _i20.PageRouteInfo { - const NewsRoute() - : super( - NewsRoute.name, - path: '', - ); - - static const String name = 'NewsRoute'; -} - -/// generated route for -/// [_i10.NewsDetailsPage] -class NewsDetailsRoute extends _i20.PageRouteInfo { - NewsDetailsRoute({ - _i21.Key? key, - required _i24.NewsItem newsItem, - }) : super( - NewsDetailsRoute.name, - path: 'details', - args: NewsDetailsRouteArgs( - key: key, - newsItem: newsItem, - ), - ); - - static const String name = 'NewsDetailsRoute'; -} - -class NewsDetailsRouteArgs { - const NewsDetailsRouteArgs({ - this.key, - required this.newsItem, - }); - - final _i21.Key? key; - - final _i24.NewsItem newsItem; - - @override - String toString() { - return 'NewsDetailsRouteArgs{key: $key, newsItem: $newsItem}'; - } -} - -/// generated route for -/// [_i11.ProfilePage] -class ProfileRoute extends _i20.PageRouteInfo { - const ProfileRoute() - : super( - ProfileRoute.name, - path: '', - ); - - static const String name = 'ProfileRoute'; -} - -/// generated route for -/// [_i12.LoginPage] -class LoginRoute extends _i20.PageRouteInfo { - const LoginRoute() - : super( - LoginRoute.name, - path: 'login', - ); - - static const String name = 'LoginRoute'; -} - -/// generated route for -/// [_i6.AboutAppPage] -class AboutAppRoute extends _i20.PageRouteInfo { - const AboutAppRoute() - : super( - AboutAppRoute.name, - path: 'about', - ); - - static const String name = 'AboutAppRoute'; -} - -/// generated route for -/// [_i13.ProfileAnnouncesPage] -class ProfileAnnouncesRoute extends _i20.PageRouteInfo { - const ProfileAnnouncesRoute() - : super( - ProfileAnnouncesRoute.name, - path: 'announces', - ); - - static const String name = 'ProfileAnnouncesRoute'; -} - -/// generated route for -/// [_i14.ProfileAttendancePage] -class ProfileAttendanceRoute extends _i20.PageRouteInfo { - const ProfileAttendanceRoute() - : super( - ProfileAttendanceRoute.name, - path: 'attendance', - ); - - static const String name = 'ProfileAttendanceRoute'; -} - -/// generated route for -/// [_i15.ProfileDetailPage] -class ProfileDetailRoute extends _i20.PageRouteInfo { - ProfileDetailRoute({ - _i21.Key? key, - required _i25.User user, - }) : super( - ProfileDetailRoute.name, - path: 'details', - args: ProfileDetailRouteArgs( - key: key, - user: user, - ), - ); - - static const String name = 'ProfileDetailRoute'; -} - -class ProfileDetailRouteArgs { - const ProfileDetailRouteArgs({ - this.key, - required this.user, - }); - - final _i21.Key? key; - - final _i25.User user; - - @override - String toString() { - return 'ProfileDetailRouteArgs{key: $key, user: $user}'; - } -} - -/// generated route for -/// [_i16.ProfileLectrosPage] -class ProfileLectrosRoute extends _i20.PageRouteInfo { - const ProfileLectrosRoute() - : super( - ProfileLectrosRoute.name, - path: 'lectors', - ); - - static const String name = 'ProfileLectrosRoute'; -} - -/// generated route for -/// [_i17.ProfileScoresPage] -class ProfileScoresRoute extends _i20.PageRouteInfo { - const ProfileScoresRoute() - : super( - ProfileScoresRoute.name, - path: 'scores', - ); - - static const String name = 'ProfileScoresRoute'; -} - -/// generated route for -/// [_i18.ProfileSettingsPage] -class ProfileSettingsRoute extends _i20.PageRouteInfo { - const ProfileSettingsRoute() - : super( - ProfileSettingsRoute.name, - path: 'settings', - ); - - static const String name = 'ProfileSettingsRoute'; -} - -/// generated route for -/// [_i19.ProfileNfcPassPage] -class ProfileNfcPassRoute extends _i20.PageRouteInfo { - const ProfileNfcPassRoute() - : super( - ProfileNfcPassRoute.name, - path: 'nfc-pass', - ); - - static const String name = 'ProfileNfcPassRoute'; -} diff --git a/lib/presentation/pages/home_page.dart b/lib/presentation/pages/home_page.dart index 8edd269f..e43194a0 100644 --- a/lib/presentation/pages/home_page.dart +++ b/lib/presentation/pages/home_page.dart @@ -1,181 +1,23 @@ -import 'dart:io' show Platform; -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/presentation/bloc/app_cubit/app_cubit.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; -import 'package:rtu_mirea_app/presentation/theme.dart'; -import 'package:rtu_mirea_app/presentation/typography.dart'; -import 'package:salomon_bottom_bar/salomon_bottom_bar.dart'; -import 'package:unicons/unicons.dart'; - -import '../constants.dart'; class HomePage extends StatelessWidget { const HomePage({Key? key}) : super(key: key); - static final isDesktop = !(Platform.isAndroid || Platform.isIOS); - @override Widget build(BuildContext context) { - AutoRouter.of(context); // <-- this is needed to initialize the router + context.read().checkOnboarding(); return BlocConsumer(builder: (context, state) { - if (state is AppClean) { - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > tabletBreakpoint) { - return Scaffold( - appBar: AppBar(title: const Text('RTU MIREA App')), - body: AutoTabsRouter( - routes: [ - const NewsRouter(), - const ScheduleRouter(), - const MapRoute(), - isDesktop - ? const AboutAppDesktopRoute() - : const ProfileRouter(), - ], - builder: (context, child, animation) { - return Row( - children: [ - _buildSidebar(context), - Expanded(child: child), - ], - ); - }, - ), - ); - } else { - return AutoTabsScaffold( - routes: [ - const NewsRouter(), - const ScheduleRouter(), - const MapRoute(), - isDesktop - ? const AboutAppDesktopRoute() - : const ProfileRouter(), - ], - navigatorObservers: () => [ - HeroController(), - ], - bottomNavigationBuilder: (context, tabsRouter) { - return AppBottomNavigationBar( - index: tabsRouter.activeIndex, - onClick: tabsRouter.setActiveIndex, - ); - }, - ); - } - }, - ); - } - - context.read().checkOnboarding(); return Container(); }, listener: (context, state) { if (state is AppOnboarding) { - context.router.replace(const OnBoardingRoute()); + // replace to avoid display bottom nav bar + context.replace('/onboarding'); + } else { + context.go('/schedule'); } }); } - - Widget _buildSidebar(BuildContext context) { - final tabsRouter = AutoTabsRouter.of(context); - return Container( - width: sidebarWith, - color: AppTheme.colors.background01, - child: ListView( - children: [ - ListTile( - leading: const Icon(Icons.library_books_rounded), - title: Text("Новости", style: AppTextStyle.tab), - selected: tabsRouter.activeIndex == 0, - onTap: () => tabsRouter.setActiveIndex(0), - selectedColor: AppTheme.colors.primary, - ), - ListTile( - leading: const Icon(Icons.calendar_today_rounded), - title: Text("Расписание", style: AppTextStyle.tab), - selected: tabsRouter.activeIndex == 1, - onTap: () => tabsRouter.setActiveIndex(1), - selectedColor: AppTheme.colors.primary, - ), - ListTile( - leading: const Icon(Icons.map_rounded), - title: Text("Карта", style: AppTextStyle.tab), - selected: tabsRouter.activeIndex == 2, - onTap: () => tabsRouter.setActiveIndex(2), - selectedColor: AppTheme.colors.primary, - ), - isDesktop - ? ListTile( - leading: const Icon(UniconsLine.info_circle), - title: Text("О приложении", style: AppTextStyle.tab), - selected: tabsRouter.activeIndex == 3, - onTap: () => tabsRouter.setActiveIndex(3), - selectedColor: AppTheme.colors.primary, - ) - : ListTile( - leading: const Icon(Icons.person), - title: Text("Профиль", style: AppTextStyle.tab), - selected: tabsRouter.activeIndex == 3, - onTap: () => tabsRouter.setActiveIndex(3), - selectedColor: AppTheme.colors.primary, - ), - ], - ), - ); - } -} - -class AppBottomNavigationBar extends StatelessWidget { - const AppBottomNavigationBar( - {Key? key, required this.index, required this.onClick}) - : super(key: key); - - final Function(int) onClick; - final int index; - - @override - Widget build(BuildContext context) { - return Container( - color: AppTheme.colors.background01, - child: SalomonBottomBar( - margin: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 10, - ), - currentIndex: index, - onTap: onClick, - items: [ - SalomonBottomBarItem( - icon: const Icon(Icons.library_books_rounded), - title: const Text("Новости"), - selectedColor: AppTheme.colors.primary, - ), - SalomonBottomBarItem( - icon: const Icon(Icons.calendar_today_rounded), - title: const Text("Расписание"), - selectedColor: AppTheme.colors.primary, - ), - SalomonBottomBarItem( - icon: const Icon(Icons.map_rounded), - title: const Text("Карта"), - selectedColor: AppTheme.colors.primary, - ), - HomePage.isDesktop - ? SalomonBottomBarItem( - icon: const Icon(UniconsLine.info_circle), - title: const Text("О приложении"), - selectedColor: AppTheme.colors.primary, - ) - : SalomonBottomBarItem( - icon: const Icon(Icons.person), - title: const Text("Профиль"), - selectedColor: AppTheme.colors.primary, - ), - ], - ), - ); - } } diff --git a/lib/presentation/pages/login/login_page.dart b/lib/presentation/pages/login/login_page.dart index c1fc21b4..db120382 100644 --- a/lib/presentation/pages/login/login_page.dart +++ b/lib/presentation/pages/login/login_page.dart @@ -1,6 +1,6 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/primary_button.dart'; @@ -27,7 +27,7 @@ class _LoginPageState extends State { listener: (context, state) { state.whenOrNull( logInSuccess: (st) { - context.router.pop(); + context.pop(); }, ); }, diff --git a/lib/presentation/pages/news/news_page.dart b/lib/presentation/pages/news/news_page.dart index 0461046e..93985d00 100644 --- a/lib/presentation/pages/news/news_page.dart +++ b/lib/presentation/pages/news/news_page.dart @@ -1,48 +1,19 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/domain/entities/news_item.dart'; import 'package:rtu_mirea_app/presentation/bloc/news_bloc/news_bloc.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/app_settings_button.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/primary_tab_button.dart'; +import 'package:rtu_mirea_app/presentation/widgets/page_with_theme_consumer.dart'; import 'package:shimmer/shimmer.dart'; import 'widgets/news_card.dart'; import 'widgets/tag_badge.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; -class NewsPage extends StatefulWidget { - const NewsPage({Key? key}) : super(key: key); - - @override - State createState() => _NewsPageState(); -} - -class _NewsPageState extends State { - late final ScrollController _scrollController; - - @override - void initState() { - super.initState(); - _scrollController = ScrollController()..addListener(_scrollListener); - } - - @override - void dispose() { - _scrollController.removeListener(_scrollListener); - super.dispose(); - } - - void _scrollListener() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200) { - if (context.read().state is! NewsLoading) { - context.read().add(NewsLoadEvent( - isImportant: _tabValueNotifier.value == 1, - )); - } - } - } +class NewsPage extends PageWithThemeConsumer { + NewsPage({super.key}); final ValueNotifier _tabValueNotifier = ValueNotifier(0); @@ -79,7 +50,7 @@ class _NewsPageState extends State { const EdgeInsets.only(top: 4, bottom: 16, left: 24, right: 24), child: BlocConsumer( listener: (context, state) => - state.runtimeType != NewsLoaded ? context.router.pop() : null, + state.runtimeType != NewsLoaded ? context.pop() : null, buildWhen: (previous, current) => (current is NewsLoaded), builder: (context, state) { final loadedState = state as NewsLoaded; @@ -177,29 +148,8 @@ class _NewsPageState extends State { ); } - int _getColumnCount(double screenWidth) { - if (screenWidth < 900) { - return 1; - } else if (screenWidth < 1200) { - return 3; - } else { - return 4; - } - } - - double _computeWidth(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - - // Compute view size for desktop with sidebar - final viewSize = screenWidth > 600 ? screenWidth - 240 : screenWidth; - - final columnCount = _getColumnCount(viewSize); - - return (viewSize - (columnCount - 1) * 10) / columnCount; - } - @override - Widget build(BuildContext context) { + Widget buildPage(BuildContext context) { return Scaffold( backgroundColor: AppTheme.colors.background01, appBar: AppBar( @@ -251,32 +201,10 @@ class _NewsPageState extends State { ), ); } - return SingleChildScrollView( - controller: _scrollController, - child: Wrap( - alignment: WrapAlignment.start, - spacing: 0, - runSpacing: 0, - children: [ - const SizedBox( - width: double.infinity, - height: 16, - ), - for (var index = 0; index < news.length; index++) - SizedBox( - width: _computeWidth(context), - child: NewsCard( - newsItem: news[index], - onClickNewsTag: (tag) => _filterNewsByTag( - context.read(), tag), - ), - ), - if (isLoading) - const Center( - child: CircularProgressIndicator(), - ), - ], - ), + return _NewsPageView( + tabValueNotifier: _tabValueNotifier, + news: news, + isLoading: isLoading, ); }, ), @@ -289,6 +217,105 @@ class _NewsPageState extends State { } } +class _NewsPageView extends StatefulWidget { + const _NewsPageView({ + Key? key, + required this.tabValueNotifier, + required this.news, + required this.isLoading, + }) : super(key: key); + + final ValueNotifier tabValueNotifier; + final List news; + final bool isLoading; + + @override + _NewsPageViewState createState() => _NewsPageViewState(); +} + +class _NewsPageViewState extends State<_NewsPageView> { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_scrollListener); + } + + @override + void dispose() { + _scrollController.removeListener(_scrollListener); + super.dispose(); + } + + void _scrollListener() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + if (context.read().state is! NewsLoading) { + context.read().add(NewsLoadEvent( + isImportant: widget.tabValueNotifier.value == 1, + )); + } + } + } + + int _getColumnCount(double screenWidth) { + if (screenWidth < 900) { + return 1; + } else if (screenWidth < 1200) { + return 3; + } else { + return 4; + } + } + + double _computeWidth(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + // Compute view size for desktop with sidebar + final viewSize = screenWidth > 600 ? screenWidth - 240 : screenWidth; + + final columnCount = _getColumnCount(viewSize); + + return (viewSize - (columnCount - 1) * 10) / columnCount; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + controller: _scrollController, + child: Wrap( + alignment: WrapAlignment.start, + spacing: 0, + runSpacing: 0, + children: [ + const SizedBox( + width: double.infinity, + height: 16, + ), + for (var index = 0; index < widget.news.length; index++) + SizedBox( + width: _computeWidth(context), + child: NewsCard( + newsItem: widget.news[index], + onClickNewsTag: (tag) => context.read().add( + NewsLoadEvent( + refresh: true, + isImportant: widget.tabValueNotifier.value == 1, + tag: tag, + ), + )), + ), + if (widget.isLoading) + const Center( + child: CircularProgressIndicator(), + ), + ], + ), + ); + } +} + /// Widget with news card loading animation (shimmer effect). /// Used for first-time loading. class _ShimmerNewsCardLoading extends StatelessWidget { diff --git a/lib/presentation/pages/news/widgets/news_card.dart b/lib/presentation/pages/news/widgets/news_card.dart index 70cf582e..819d574d 100644 --- a/lib/presentation/pages/news/widgets/news_card.dart +++ b/lib/presentation/pages/news/widgets/news_card.dart @@ -1,11 +1,10 @@ -import 'package:auto_route/auto_route.dart'; import 'package:extended_image/extended_image.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/domain/entities/news_item.dart'; import 'package:intl/intl.dart'; import 'package:rtu_mirea_app/presentation/bloc/news_bloc/news_bloc.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/pages/news/widgets/tag_badge.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:shimmer/shimmer.dart'; @@ -22,9 +21,8 @@ class NewsCard extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - context.router.push(NewsDetailsRoute( - newsItem: newsItem, - )); + context.go('/news/details', extra: newsItem); + FirebaseAnalytics.instance.logEvent(name: 'view_news', parameters: { 'news_title': newsItem.title, }); diff --git a/lib/presentation/pages/onboarding/onboarding_page.dart b/lib/presentation/pages/onboarding/onboarding_page.dart index b5f8c16e..c21e2913 100644 --- a/lib/presentation/pages/onboarding/onboarding_page.dart +++ b/lib/presentation/pages/onboarding/onboarding_page.dart @@ -1,9 +1,8 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:rtu_mirea_app/presentation/bloc/app_cubit/app_cubit.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; import 'widgets/indicator.dart'; import 'widgets/next_button.dart'; @@ -218,7 +217,7 @@ class _PageIndicatorsState extends State { : InkWell( onTap: () { context.read().closeOnboarding(); - context.router.replace(const HomeRoute()); + context.go('/schedule'); }, child: Text( "Пропустить", diff --git a/lib/presentation/pages/onboarding/widgets/next_button.dart b/lib/presentation/pages/onboarding/widgets/next_button.dart index 87d2c6be..b43600e1 100644 --- a/lib/presentation/pages/onboarding/widgets/next_button.dart +++ b/lib/presentation/pages/onboarding/widgets/next_button.dart @@ -1,8 +1,7 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/src/provider.dart'; import 'package:rtu_mirea_app/presentation/bloc/app_cubit/app_cubit.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; @@ -22,7 +21,7 @@ class NextPageViewButton extends StatelessWidget { onPressed: () { if (isLastPage) { context.read().closeOnboarding(); - context.router.replace(const HomeRoute()); + context.go('/schedule'); } else { onClick(); } diff --git a/lib/presentation/pages/profile/notifications_settings_page.dart b/lib/presentation/pages/profile/notifications_settings_page.dart new file mode 100644 index 00000000..0cf7f674 --- /dev/null +++ b/lib/presentation/pages/profile/notifications_settings_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rtu_mirea_app/domain/entities/user.dart'; +import 'package:rtu_mirea_app/presentation/bloc/notification_preferences/notification_preferences_bloc.dart'; +import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; +import 'package:rtu_mirea_app/presentation/theme.dart'; +import 'package:rtu_mirea_app/presentation/typography.dart'; + +class NotificationsSettingsPage extends StatelessWidget { + const NotificationsSettingsPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Настройки уведомлений"), + ), + body: SafeArea( + bottom: false, + child: BlocBuilder( + builder: (context, state) { + return state.maybeMap( + logInSuccess: (st) => _NotificationPreferencesView(user: st.user), + orElse: () => const Center( + child: Text("Необходимо авторизоваться"), + ), + ); + }, + ), + ), + ); + } +} + +class _NotificationPreferencesView extends StatelessWidget { + const _NotificationPreferencesView({ + Key? key, + required this.user, + }) : super(key: key); + + final User user; + + String _getDescription(String category) { + switch (category) { + case 'Объявления': + return 'Важные общеуниверситетские объявления'; + case 'Обновления расписания': + return 'Изменения в расписании вашей группы'; + default: + return ''; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + 'Категории уведомлений', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Expanded( + child: BlocBuilder( + builder: (context, state) { + return ListView( + children: state.categories + .map( + (category) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _NotificationsSwitch( + name: category, + description: _getDescription(category), + value: state.selectedCategories.contains(category), + onChanged: (value) => + context.read().add( + CategoriesPreferenceToggled( + category: category, + group: UserBloc.getActiveStudent(user) + .academicGroup, + ), + ), + ), + ), + ) + .toList(), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _NotificationsSwitch extends StatelessWidget { + final String name; + final String description; + final bool value; + final Function(bool) onChanged; + + const _NotificationsSwitch({ + Key? key, + required this.name, + required this.description, + required this.value, + required this.onChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return InkWell( + highlightColor: Colors.transparent, + splashColor: Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width - 120, + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: AppTextStyle.buttonL), + const SizedBox(height: 4), + Text( + description, + style: AppTextStyle.body, + maxLines: 2, + overflow: TextOverflow.clip, + softWrap: true, + ), + ]), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: CupertinoSwitch( + activeColor: AppTheme.colors.primary, + value: value, + onChanged: onChanged, + ), + ), + ], + ), + onTap: () { + onChanged(!value); + }, + ); + } +} diff --git a/lib/presentation/pages/profile/profile_nfc_pass_page.dart b/lib/presentation/pages/profile/profile_nfc_pass_page.dart index 025af550..466ae469 100644 --- a/lib/presentation/pages/profile/profile_nfc_pass_page.dart +++ b/lib/presentation/pages/profile/profile_nfc_pass_page.dart @@ -59,9 +59,7 @@ class _ProfileNfcPageState extends State { return state.maybeMap( logInSuccess: (state) { final user = state.user; - var student = user.students.firstWhereOrNull( - (element) => element.status == 'активный'); - student ??= user.students.first; + var student = UserBloc.getActiveStudent(user); return ListView( children: [ @@ -98,7 +96,7 @@ class _ProfileNfcPageState extends State { initial: (_) { context.read().add( NfcPassEvent.getNfcPasses( - student!.code, + student.code, student.id, snapshot.data!.id, ), @@ -122,7 +120,7 @@ class _ProfileNfcPageState extends State { onPressed: () => context.read().add( NfcPassEvent.connectNfcPass( - student!.code, + student.code, student.id, snapshot.data!.id, snapshot.data!.model, @@ -177,7 +175,7 @@ class _ProfileNfcPageState extends State { onClick: () { context.read().add( NfcPassEvent.connectNfcPass( - student!.code, + student.code, student.id, snapshot.data!.id, snapshot.data!.model, @@ -222,7 +220,7 @@ class _ProfileNfcPageState extends State { .add( NfcFeedbackEvent.sendFeedback( fullName: fullName, - group: student!.academicGroup, + group: student.academicGroup, personalNumber: student.personalNumber, studentId: @@ -230,7 +228,7 @@ class _ProfileNfcPageState extends State { ), ), fullName: fullName, - personalNumber: student!.personalNumber, + personalNumber: student.personalNumber, ); }, loading: (_) => const Center( diff --git a/lib/presentation/pages/profile/profile_page.dart b/lib/presentation/pages/profile/profile_page.dart index 89e8527f..b05a3fe8 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -1,14 +1,15 @@ import 'dart:io'; -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/domain/entities/user.dart'; +import 'package:rtu_mirea_app/presentation/bloc/notification_preferences/notification_preferences_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/colorful_button.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/icon_button.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/settings_button.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; +import 'package:rtu_mirea_app/presentation/widgets/page_with_theme_consumer.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../bloc/announces_bloc/announces_bloc.dart'; @@ -17,16 +18,11 @@ import '../../widgets/container_label.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; -class ProfilePage extends StatefulWidget { +class ProfilePage extends PageWithThemeConsumer { const ProfilePage({Key? key}) : super(key: key); @override - State createState() => _ProfilePageState(); -} - -class _ProfilePageState extends State { - @override - Widget build(BuildContext context) { + Widget buildPage(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("Профиль"), @@ -61,7 +57,15 @@ class _ProfilePageState extends State { ), ), logInError: (st) => const _InitialProfileStatePage(), - logInSuccess: (st) => _UserLoggedInView(user: st.user), + logInSuccess: (st) { + BlocProvider.of(context) + .add( + InitialCategoriesPreferencesRequested( + group: UserBloc.getActiveStudent(st.user) + .academicGroup), + ); + return _UserLoggedInView(user: st.user); + }, ); }, ), @@ -96,23 +100,17 @@ class _UserLoggedInView extends StatelessWidget { style: AppTextStyle.h5, ), ), - ShaderMask( - shaderCallback: (bounds) => AppTheme.colors.gradient07.createShader( - Rect.fromLTWH(0, 0, bounds.width, bounds.height), - ), - child: Text( - user.login, - style: AppTextStyle.titleS, - ), + Text( + user.login, + style: + AppTextStyle.titleS.copyWith(color: AppTheme.colors.colorful04), ), const SizedBox(height: 12), Row(mainAxisAlignment: MainAxisAlignment.center, children: [ TextOutlinedButton( width: 160, content: "Профиль", - onPressed: () => context.router.push( - ProfileDetailRoute(user: user), - ), + onPressed: () => context.go('/profile/details', extra: user), ), const SizedBox(width: 12), SizedBox( @@ -137,9 +135,7 @@ class _UserLoggedInView extends StatelessWidget { icon: Icons.message_rounded, onClick: () { context.read().add(const LoadAnnounces()); - context.router.push( - const ProfileAnnouncesRoute(), - ); + context.go('/profile/announces'); }), // const SizedBox(height: 8), // SettingsButton( @@ -150,24 +146,24 @@ class _UserLoggedInView extends StatelessWidget { SettingsButton( text: 'Преподаватели', icon: Icons.people_alt_rounded, - onClick: () => context.router.push(const ProfileLectrosRoute()), + onClick: () => context.go('/profile/lectors'), ), const SizedBox(height: 8), SettingsButton( text: 'Посещения', icon: Icons.access_time_rounded, - onClick: () => context.router.push(const ProfileAttendanceRoute()), + onClick: () => context.go('/profile/attendance'), ), const SizedBox(height: 8), SettingsButton( text: 'Зачетная книжка', icon: Icons.menu_book_rounded, - onClick: () => context.router.push(const ProfileScoresRoute())), + onClick: () => context.go('/profile/scores')), const SizedBox(height: 8), SettingsButton( text: 'О приложении', icon: Icons.apps_rounded, - onClick: () => context.router.push(const AboutAppRoute()), + onClick: () => context.go('/profile/about'), ), // Display only for android devices because of @@ -177,7 +173,7 @@ class _UserLoggedInView extends StatelessWidget { SettingsButton( text: 'NFC пропуск', icon: Icons.nfc_rounded, - onClick: () => context.router.push(const ProfileNfcPassRoute()), + onClick: () => context.go('/profile/nfc-pass'), ), ], @@ -185,9 +181,7 @@ class _UserLoggedInView extends StatelessWidget { SettingsButton( text: 'Настройки', icon: Icons.settings_rounded, - onClick: () => { - context.router.push(const ProfileSettingsRoute()), - }), + onClick: () => context.go('/profile/settings')), const SizedBox(height: 8), ColorfulButton( text: 'Выйти', @@ -231,13 +225,13 @@ class _InitialProfileStatePage extends StatelessWidget { SettingsButton( text: 'О приложении', icon: Icons.apps_rounded, - onClick: () => context.router.push(const AboutAppRoute()), + onClick: () => context.go('/profile/about'), ), const SizedBox(height: 8), SettingsButton( text: 'Настройки', icon: Icons.settings_rounded, - onClick: () => context.router.push(const ProfileSettingsRoute()), + onClick: () => context.go('/profile/settings'), ), ], ); diff --git a/lib/presentation/pages/profile/profile_scores_page.dart b/lib/presentation/pages/profile/profile_scores_page.dart index 0717cf36..74791900 100644 --- a/lib/presentation/pages/profile/profile_scores_page.dart +++ b/lib/presentation/pages/profile/profile_scores_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get/get.dart'; import 'package:rtu_mirea_app/domain/entities/score.dart'; import 'package:rtu_mirea_app/presentation/bloc/scores_bloc/scores_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; @@ -61,9 +60,7 @@ class _ProfileScoresPageState extends State { BlocBuilder( builder: (context, state) { final user = userStateLoaded.user; - var student = user.students.firstWhereOrNull( - (element) => element.status == 'активный'); - student ??= user.students.first; + var student = UserBloc.getActiveStudent(user); if (state is ScoresInitial) { context diff --git a/lib/presentation/pages/profile/profile_settings_page.dart b/lib/presentation/pages/profile/profile_settings_page.dart index 7c426035..b9967e16 100644 --- a/lib/presentation/pages/profile/profile_settings_page.dart +++ b/lib/presentation/pages/profile/profile_settings_page.dart @@ -1,8 +1,7 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:rtu_mirea_app/presentation/app_notifier.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; @@ -11,29 +10,6 @@ class ProfileSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final router = AutoRouter.of(context); - - void rebuildRouterStack(StackRouter router) { - final tabsRouter = AutoTabsRouter.of(context); - - // Rebuild current router stack - router.popUntil((route) => false); - router.replaceAll([const ProfileRoute()]); - - final currentTabIndex = tabsRouter.activeIndex; - - // Rebuild tabs router stack - for (var i = 0; i < tabsRouter.pageCount; i++) { - if (i == currentTabIndex) continue; - final route = tabsRouter.stackRouterOfIndex(i); - final routeName = route?.current.name; - if (routeName == null) continue; - - route?.popUntil((route) => false); - route?.pushNamed(routeName); - } - } - return Scaffold( appBar: AppBar( title: const Text("Настройки"), @@ -63,8 +39,7 @@ class ProfileSettingsPage extends StatelessWidget { .read() .updateTheme(AppThemeType.light); // Close dialog - Navigator.pop(context); - rebuildRouterStack(router); + context.pop(); }, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -82,8 +57,7 @@ class ProfileSettingsPage extends StatelessWidget { .read() .updateTheme(AppThemeType.dark); // Close dialog - Navigator.pop(context); - rebuildRouterStack(router); + context.pop(); }, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -98,6 +72,14 @@ class ProfileSettingsPage extends StatelessWidget { }, ), const Divider(), + ListTile( + title: Text("Уведомления", style: AppTextStyle.body), + leading: Icon(Icons.notifications, color: AppTheme.colors.active), + onTap: () { + context.go("/profile/settings/notifications"); + }, + ), + const Divider(), ], ), ), diff --git a/lib/presentation/pages/scaffold_with_nav_bar.dart b/lib/presentation/pages/scaffold_with_nav_bar.dart new file mode 100644 index 00000000..5606e1a3 --- /dev/null +++ b/lib/presentation/pages/scaffold_with_nav_bar.dart @@ -0,0 +1,156 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:rtu_mirea_app/presentation/app_notifier.dart'; +import 'package:rtu_mirea_app/presentation/theme.dart'; +import 'package:rtu_mirea_app/presentation/typography.dart'; +import 'package:salomon_bottom_bar/salomon_bottom_bar.dart'; +import 'package:unicons/unicons.dart'; + +import '../constants.dart'; + +class ScaffoldWithNavBar extends StatelessWidget { + const ScaffoldWithNavBar({Key? key, required this.navigationShell}) + : super(key: key); + + final StatefulNavigationShell navigationShell; + + static final isDesktop = !(Platform.isAndroid || Platform.isIOS); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > tabletBreakpoint) { + return Scaffold( + appBar: AppBar(title: const Text('РТУ МИРЭА')), + body: Row( + children: [ + _buildSidebar(context), + Expanded( + child: Consumer( + builder: (context, value, child) => navigationShell)), + ], + ), + ); + } else { + return Scaffold( + body: Consumer( + builder: (context, value, child) => navigationShell), + bottomNavigationBar: AppBottomNavigationBar( + index: navigationShell.currentIndex, + onClick: (index) => _setActiveIndex(index), + ), + ); + } + }, + ); + } + + void _setActiveIndex(int index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + } + + Widget _buildSidebar(BuildContext context) { + return Container( + width: sidebarWith, + color: AppTheme.colors.background01, + child: ListView( + children: [ + ListTile( + leading: const Icon(Icons.library_books_rounded), + title: Text("Новости", style: AppTextStyle.tab), + selected: navigationShell.currentIndex == 0, + onTap: () => _setActiveIndex(0), + selectedColor: AppTheme.colors.primary, + ), + ListTile( + leading: const Icon(Icons.calendar_today_rounded), + title: Text("Расписание", style: AppTextStyle.tab), + selected: navigationShell.currentIndex == 1, + onTap: () => _setActiveIndex(1), + selectedColor: AppTheme.colors.primary, + ), + ListTile( + leading: const Icon(Icons.map_rounded), + title: Text("Карта", style: AppTextStyle.tab), + selected: navigationShell.currentIndex == 2, + onTap: () => _setActiveIndex(2), + selectedColor: AppTheme.colors.primary, + ), + isDesktop + ? ListTile( + leading: const Icon(UniconsLine.info_circle), + title: Text("О приложении", style: AppTextStyle.tab), + selected: navigationShell.currentIndex == 3, + onTap: () => _setActiveIndex(3), + selectedColor: AppTheme.colors.primary, + ) + : ListTile( + leading: const Icon(Icons.person), + title: Text("Профиль", style: AppTextStyle.tab), + selected: navigationShell.currentIndex == 3, + onTap: () => _setActiveIndex(3), + selectedColor: AppTheme.colors.primary, + ), + ], + ), + ); + } +} + +class AppBottomNavigationBar extends StatelessWidget { + const AppBottomNavigationBar( + {Key? key, required this.index, required this.onClick}) + : super(key: key); + + final Function(int) onClick; + final int index; + + @override + Widget build(BuildContext context) { + return Container( + color: AppTheme.colors.background01, + child: SalomonBottomBar( + margin: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + currentIndex: index, + onTap: onClick, + items: [ + SalomonBottomBarItem( + icon: const Icon(Icons.library_books_rounded), + title: const Text("Новости"), + selectedColor: AppTheme.colors.primary, + ), + SalomonBottomBarItem( + icon: const Icon(Icons.calendar_today_rounded), + title: const Text("Расписание"), + selectedColor: AppTheme.colors.primary, + ), + SalomonBottomBarItem( + icon: const Icon(Icons.map_rounded), + title: const Text("Карта"), + selectedColor: AppTheme.colors.primary, + ), + ScaffoldWithNavBar.isDesktop + ? SalomonBottomBarItem( + icon: const Icon(UniconsLine.info_circle), + title: const Text("О приложении"), + selectedColor: AppTheme.colors.primary, + ) + : SalomonBottomBarItem( + icon: const Icon(Icons.person), + title: const Text("Профиль"), + selectedColor: AppTheme.colors.primary, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/schedule/groups_select_page.dart b/lib/presentation/pages/schedule/groups_select_page.dart index 64c02fc0..601609fe 100644 --- a/lib/presentation/pages/schedule/groups_select_page.dart +++ b/lib/presentation/pages/schedule/groups_select_page.dart @@ -1,8 +1,8 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/presentation/bloc/schedule_bloc/schedule_bloc.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; @@ -159,7 +159,7 @@ class _GroupsSelectPageState extends State { ScheduleSetActiveGroupEvent( group: _filteredGroups[index]), ); - context.router.pop(); + context.pop(); }, ); }, @@ -295,7 +295,7 @@ class _GroupListTile extends StatelessWidget { context .read() .add(ScheduleSetActiveGroupEvent(group: group)); - context.router.pop(); + context.pop(); }, ), ), diff --git a/lib/presentation/pages/schedule/schedule_page.dart b/lib/presentation/pages/schedule/schedule_page.dart index f09d3d1f..ce38e256 100644 --- a/lib/presentation/pages/schedule/schedule_page.dart +++ b/lib/presentation/pages/schedule/schedule_page.dart @@ -1,46 +1,26 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/domain/entities/schedule.dart'; import 'package:rtu_mirea_app/presentation/bloc/schedule_bloc/schedule_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; import 'package:rtu_mirea_app/presentation/constants.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/pages/schedule/widgets/schedule_settings_drawer.dart'; -import 'package:rtu_mirea_app/presentation/pages/schedule/widgets/schedule_settings_modal.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/colorful_button.dart'; +import 'package:rtu_mirea_app/presentation/widgets/page_with_theme_consumer.dart'; import 'package:rtu_mirea_app/presentation/widgets/settings_switch_button.dart'; import '../../widgets/feedback_modal.dart'; import 'widgets/schedule_page_view.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; -class SchedulePage extends StatefulWidget { - const SchedulePage({Key? key}) : super(key: key); - - @override - State createState() => _SchedulePageState(); -} - -class _SchedulePageState extends State { - bool _modalShown = false; - - @override - void initState() { - super.initState(); - } - @override - void dispose() { - super.dispose(); - // // dispose mounted modal - // if (_modalShown) { - // Navigator.of(context).pop(); - // } - } +class SchedulePage extends PageWithThemeConsumer { + const SchedulePage({Key? key}) : super(key: key); Widget _buildGroupButton( + BuildContext context, String group, String activeGroup, bool isActive, @@ -153,174 +133,209 @@ class _SchedulePageState extends State { } } - /// Show modal with group settings. If [_modalShown] is true, then modal is - /// already shown and we don't need to show it again. - void _showModal({bool? isFirstRun}) { - if (!_modalShown) { - _modalShown = true; - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => - ScheduleSettingsModal(isFirstRun: isFirstRun ?? true), - ).whenComplete(() { - _modalShown = false; - }); - } - } - @override - Widget build(BuildContext context) { - return Scaffold( - endDrawer: ScheduleSettingsDrawer( - builder: (_) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - BlocBuilder( - buildWhen: (prevState, currentState) { - if (currentState is ScheduleLoaded && - prevState is ScheduleLoaded) { - if (prevState.activeGroup != currentState.activeGroup || - prevState.downloadedScheduleGroups != - currentState.downloadedScheduleGroups || - prevState.schedule.isRemote != - currentState.schedule.isRemote) return true; - } - if (currentState is ScheduleLoaded && - prevState.runtimeType != ScheduleLoaded) return true; - return false; - }, builder: (context, state) { - if (state is ScheduleLoaded || - state is ScheduleActiveGroupEmpty) { - return Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (state is ScheduleLoaded) - SettingsSwitchButton( - initialValue: - state.scheduleSettings.showEmptyLessons, - svgPicture: SvgPicture.asset( - 'assets/icons/lessons.svg', - height: 16, - width: 16, + Widget buildPage(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Scaffold( + endDrawer: ScheduleSettingsDrawer( + builder: (_) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + BlocBuilder( + buildWhen: (prevState, currentState) { + if (currentState is ScheduleLoaded && + prevState is ScheduleLoaded) { + if (prevState.activeGroup != currentState.activeGroup || + prevState.downloadedScheduleGroups != + currentState.downloadedScheduleGroups || + prevState.schedule.isRemote != + currentState.schedule.isRemote) return true; + } + if (currentState is ScheduleLoaded && + prevState.runtimeType != ScheduleLoaded) return true; + return false; + }, builder: (context, state) { + if (state is ScheduleLoaded || + state is ScheduleActiveGroupEmpty) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state is ScheduleLoaded) + SettingsSwitchButton( + initialValue: + state.scheduleSettings.showEmptyLessons, + svgPicture: SvgPicture.asset( + 'assets/icons/lessons.svg', + height: 16, + width: 16, + ), + text: "Пустые пары", + onChanged: (value) { + context.read().add( + ScheduleUpdateSettingsEvent( + showEmptyLessons: value)); + }, ), - text: "Пустые пары", - onChanged: (value) { - context.read().add( - ScheduleUpdateSettingsEvent( - showEmptyLessons: value)); - }, - ), - // SizedBox(height: 10), - // SettingsSwitchButton( - // initialValue: - // state.scheduleSettings.showLessonsNumbers, - // svgPicture: SvgPicture.asset( - // 'assets/icons/number.svg', - // height: 16, - // width: 16, - // ), - // text: "Номера пар", - // onChanged: (value) { - // context.read().add( - // ScheduleUpdateSettingsEvent( - // showLesonsNums: value)); - // }, - // ), - Material( - color: Colors.transparent, - child: InkWell( - child: Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - SvgPicture.asset( - 'assets/icons/add_group.svg', - height: 16, - width: 16, - ), - const SizedBox(width: 20), - Text("Добавить группу", - style: AppTextStyle.buttonL), - ], + // SizedBox(height: 10), + // SettingsSwitchButton( + // initialValue: + // state.scheduleSettings.showLessonsNumbers, + // svgPicture: SvgPicture.asset( + // 'assets/icons/number.svg', + // height: 16, + // width: 16, + // ), + // text: "Номера пар", + // onChanged: (value) { + // context.read().add( + // ScheduleUpdateSettingsEvent( + // showLesonsNums: value)); + // }, + // ), + Material( + color: Colors.transparent, + child: InkWell( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 20), + child: Row( + children: [ + SvgPicture.asset( + 'assets/icons/add_group.svg', + height: 16, + width: 16, + ), + const SizedBox(width: 20), + Text("Добавить группу", + style: AppTextStyle.buttonL), + ], + ), ), - ), - Opacity( - opacity: 0.05, - child: Container( - width: double.infinity, - height: 1, - color: Colors.white, + Opacity( + opacity: 0.05, + child: Container( + width: double.infinity, + height: 1, + color: Colors.white, + ), ), - ), - ], + ], + ), + // onTap: () => _showModal(isFirstRun: false), + onTap: () => context.go('/schedule/select-group'), ), - // onTap: () => _showModal(isFirstRun: false), - onTap: () => - context.router.push(const GroupsSelectRoute()), ), - ), - Material( - color: Colors.transparent, - child: InkWell( - child: Column( - children: [ - Padding( - padding: - const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - SvgPicture.asset( - 'assets/icons/social-sharing.svg', - height: 16, - width: 16, - ), - const SizedBox(width: 20), - Text("Проблемы с расписанием", - style: AppTextStyle.buttonL), - ], + Material( + color: Colors.transparent, + child: InkWell( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 20), + child: Row( + children: [ + SvgPicture.asset( + 'assets/icons/social-sharing.svg', + height: 16, + width: 16, + ), + const SizedBox(width: 20), + Text("Проблемы с расписанием", + style: AppTextStyle.buttonL), + ], + ), ), - ), - Opacity( - opacity: 0.05, - child: Container( - width: double.infinity, - height: 1, - color: Colors.white, + Opacity( + opacity: 0.05, + child: Container( + width: double.infinity, + height: 1, + color: Colors.white, + ), ), - ), - ], - ), - onTap: () { - final defaultText = state is ScheduleLoaded - ? 'Возникла проблема с расписанием группы ${state.activeGroup}:\n\n' - : null; + ], + ), + onTap: () { + final defaultText = state is ScheduleLoaded + ? 'Возникла проблема с расписанием группы ${state.activeGroup}:\n\n' + : null; - final userBloc = context.read(); + final userBloc = context.read(); - userBloc.state.maybeMap( - logInSuccess: (value) => - FeedbackBottomModalSheet.show( - context, - defaultText: defaultText, - defaultEmail: value.user.email, - ), - orElse: () => FeedbackBottomModalSheet.show( - context, - defaultText: defaultText, - ), - ); - }, + userBloc.state.maybeMap( + logInSuccess: (value) => + FeedbackBottomModalSheet.show( + context, + defaultText: defaultText, + defaultEmail: value.user.email, + ), + orElse: () => FeedbackBottomModalSheet.show( + context, + defaultText: defaultText, + ), + ); + }, + ), ), - ), - if (state is ScheduleLoaded) + if (state is ScheduleLoaded) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + Text( + "Группы".toUpperCase(), + style: AppTextStyle.chip.copyWith( + color: AppTheme.colors.deactiveDarker), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10), + _buildGroupButton( + context, + state.activeGroup, + state.activeGroup, + true, + state.schedule, + ), + Expanded( + child: ListView.builder( + itemCount: + state.downloadedScheduleGroups.length, + itemBuilder: (context, index) { + if (state.downloadedScheduleGroups[ + index] != + state.activeGroup) { + return _buildGroupButton( + context, + state.downloadedScheduleGroups[ + index], + state.activeGroup, + false, + state.schedule, + ); + } + return Container(); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } else { + // Schedule not loaded info + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -333,133 +348,72 @@ class _SchedulePageState extends State { textAlign: TextAlign.left, ), const SizedBox(height: 10), - _buildGroupButton( - state.activeGroup, - state.activeGroup, - true, - state.schedule, - ), - Expanded( - child: ListView.builder( - itemCount: - state.downloadedScheduleGroups.length, - itemBuilder: (context, index) { - if (state.downloadedScheduleGroups[ - index] != - state.activeGroup) { - return _buildGroupButton( - state.downloadedScheduleGroups[index], - state.activeGroup, - false, - state.schedule, - ); - } - return Container(); - }, - ), - ), ], ), ), - ], - ), - ); - } else { - // Schedule not loaded info - return Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 20), - Text( - "Группы".toUpperCase(), - style: AppTextStyle.chip.copyWith( - color: AppTheme.colors.deactiveDarker), - textAlign: TextAlign.left, - ), - const SizedBox(height: 10), - ], - ), - ), - ], - ), - ); - } - }), - ], + ], + ), + ); + } + }), + ], + ), ), ), - ), - appBar: AppBar( - title: const Text('Расписание'), - ), - body: Container( - color: AppTheme.colors.background01, - child: SafeArea( - child: BlocConsumer( - listener: (context, state) { - // if (state is ScheduleActiveGroupEmpty) { - // if (!_modalShown) { - // // show after 300 ms - // Future.delayed( - // const Duration(milliseconds: 300), - // () => _showModal(), - // ); - // } - // } - }, - buildWhen: (prevState, currentState) { - if (prevState is ScheduleLoaded && - currentState is ScheduleLoaded) { - return prevState != currentState; - } - return true; - }, - builder: (context, state) { - if (state is ScheduleLoading) { - // Add post frame callback to hide modal after build - // WidgetsBinding.instance.addPostFrameCallback((_) { - // if (_modalShown) { - // _modalShown = false; - // context.router.root.pop(); - // } - // }); + appBar: BlocProvider.of(context).state is ScheduleLoaded + ? null + : AppBar( + title: const Text('Расписание'), + ), + body: Container( + color: AppTheme.colors.background01, + child: SafeArea( + child: BlocBuilder( + buildWhen: (prevState, currentState) { + if (prevState is ScheduleLoaded && + currentState is ScheduleLoaded) { + return prevState != currentState; + } + return true; + }, + builder: (context, state) { + if (state is ScheduleLoading) { + // Add post frame callback to hide modal after build + // WidgetsBinding.instance.addPostFrameCallback((_) { + // if (_modalShown) { + // _modalShown = false; + // context.router.root.pop(); + // } + // }); - return Center( - child: CircularProgressIndicator( - backgroundColor: AppTheme.colors.primary, - strokeWidth: 5, - ), - ); - } else if (state is ScheduleLoaded) { - return SchedulePageView(schedule: state.schedule); - } else if (state is ScheduleLoadError) { - return Column( - children: [ - Text( - 'Упс!', - style: AppTextStyle.h3, - ), - const SizedBox( - height: 24, - ), - Text( - state.errorMessage, - style: AppTextStyle.bodyBold, - ) - ], - ); - } else { - // return _NoActiveGroupFoundMessage(onTap: () => _showModal()); - return _NoActiveGroupFoundMessage( - onTap: () => - context.router.push(const GroupsSelectRoute())); - } - }, + return const Center( + child: CircularProgressIndicator(), + ); + } else if (state is ScheduleLoaded) { + return SchedulePageView(schedule: state.schedule); + } else if (state is ScheduleLoadError) { + return Column( + children: [ + Text( + 'Упс!', + style: AppTextStyle.h3, + ), + const SizedBox( + height: 24, + ), + Text( + state.errorMessage, + style: AppTextStyle.bodyBold, + ) + ], + ); + } else { + // return _NoActiveGroupFoundMessage(onTap: () => _showModal()); + return _NoActiveGroupFoundMessage( + onTap: () => context.go('/schedule/select-group')); + } + }, + ), ), ), ), diff --git a/lib/presentation/pages/schedule/widgets/schedule_page_view.dart b/lib/presentation/pages/schedule/widgets/schedule_page_view.dart index f0b28d10..5375b8a0 100644 --- a/lib/presentation/pages/schedule/widgets/schedule_page_view.dart +++ b/lib/presentation/pages/schedule/widgets/schedule_page_view.dart @@ -37,7 +37,7 @@ class _SchedulePageViewState extends State { late int _selectedPage; late int _selectedWeek; - final _scrollNotifier = ValueNotifier(0); + late ScrollController _nestedScrollViewController; @override void initState() { @@ -53,6 +53,8 @@ class _SchedulePageViewState extends State { (BlocProvider.of(context).state as ScheduleLoaded) .scheduleSettings .calendarFormat]; + + _nestedScrollViewController = ScrollController(); } /// check if current date is before [_lastCalendarDay] and after [_firstCalendarDay] @@ -162,232 +164,37 @@ class _SchedulePageViewState extends State { lessons = _getLessonsWithEmpty(lessons, state.activeGroup); } - return NotificationListener( - onNotification: (notification) { - if (notification is ScrollUpdateNotification) { - _scrollNotifier.value = notification.metrics.extentBefore; - } - return true; - }, - child: ListView.separated( - itemCount: lessons.length, - physics: const BouncingScrollPhysics(), - itemBuilder: (context, i) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: lessons[i].name.replaceAll(' ', '') != '' - ? LessonCard( - name: lessons[i].name, - timeStart: lessons[i].timeStart, - timeEnd: lessons[i].timeEnd, - room: lessons[i].rooms.join(', '), - type: lessons[i].types, - teacher: lessons[i].teachers.join(', '), - ) - : EmptyLessonCard( - timeStart: lessons[i].timeStart, - timeEnd: lessons[i].timeEnd, - ), - ); - }, - separatorBuilder: (context, index) { - return const SizedBox(height: 8); - }, - ), - ); - } - } - - Widget _buildCalendar(bool isDesktop) { - return TableCalendar( - // pageJumpingEnabled: true, - weekendDays: const [DateTime.sunday], - - calendarBuilders: CalendarBuilders( - markerBuilder: (context, day, events) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - events.length, - (index) => Container( - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 0.3), - decoration: BoxDecoration( - color: LessonCard.getColorByType( - (events[index] as Lesson).types), - shape: BoxShape.circle, - ), - ), - ), + return ListView.separated( + itemCount: lessons.length, + itemBuilder: (context, i) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: lessons[i].name.replaceAll(' ', '') != '' + ? LessonCard( + name: lessons[i].name, + timeStart: lessons[i].timeStart, + timeEnd: lessons[i].timeEnd, + room: lessons[i].rooms.join(', '), + type: lessons[i].types, + teacher: lessons[i].teachers.join(', '), + ) + : EmptyLessonCard( + timeStart: lessons[i].timeStart, + timeEnd: lessons[i].timeEnd, + ), ); }, - ), - calendarFormat: isDesktop ? CalendarFormat.month : _calendarFormat, - firstDay: _firstCalendarDay, - lastDay: _lastCalendarDay, - sixWeekMonthsEnforced: true, - startingDayOfWeek: StartingDayOfWeek.monday, - headerStyle: HeaderStyle( - formatButtonVisible: !isDesktop, - formatButtonShowsNext: false, - titleTextStyle: AppTextStyle.captionL, - formatButtonTextStyle: AppTextStyle.buttonS, - titleTextFormatter: (DateTime date, dynamic locale) { - String dateStr = DateFormat.yMMMM(locale).format(date); - String weekStr = _selectedWeek.toString(); - return '$dateStr\nвыбрана $weekStr неделя'; + separatorBuilder: (context, index) { + return const SizedBox(height: 8); }, - formatButtonDecoration: BoxDecoration( - border: Border.fromBorderSide( - BorderSide(color: AppTheme.colors.deactive)), - borderRadius: const BorderRadius.all(Radius.circular(12.0))), - ), - calendarStyle: CalendarStyle( - rangeHighlightColor: AppTheme.colors.secondary, - cellAlignment: Alignment.center, - cellMargin: const EdgeInsets.all(10), - ), - daysOfWeekStyle: DaysOfWeekStyle( - weekdayStyle: - AppTextStyle.body.copyWith(color: AppTheme.colors.deactive), - weekendStyle: - AppTextStyle.body.copyWith(color: AppTheme.colors.deactiveDarker), - ), - focusedDay: _focusedDay, - availableCalendarFormats: const { - CalendarFormat.month: 'Месяц', - CalendarFormat.twoWeeks: '2 недели', - CalendarFormat.week: 'Неделя' - }, - eventLoader: (day) { - final int week = CalendarUtils.getCurrentWeek(mCurrentDate: day); - final int weekday = day.weekday - 1; - - final lessons = _getLessonsByWeek(week, widget.schedule); - if (weekday == 6) { - return []; - } else { - final Map uniqueLessonEvents = {}; - - for (var lesson in lessons[weekday]) { - if (uniqueLessonEvents.containsKey(lesson.timeStart)) { - continue; - } - - uniqueLessonEvents[lesson.timeStart] = lesson; - } - - return uniqueLessonEvents.values.toList(); - } - }, - locale: 'ru_RU', - selectedDayPredicate: (day) { - // Use `selectedDayPredicate` to determine which day is currently selected. - // If this returns true, then `day` will be marked as selected. - - // Using `isSameDay` is recommended to disregard - // the time-part of compared DateTime objects. - return isSameDay(_selectedDay, day); - }, - onDaySelected: (selectedDay, focusedDay) { - if (!isSameDay(_selectedDay, selectedDay)) { - final int currentNewWeek = - CalendarUtils.getCurrentWeek(mCurrentDate: selectedDay); - // Call `setState()` when updating the selected day - setState(() { - _selectedWeek = currentNewWeek; - _selectedPage = selectedDay.difference(_firstCalendarDay).inDays; - _selectedDay = selectedDay; - _focusedDay = _validateDayInRange(focusedDay); - _controller.jumpToPage(_selectedPage); - }); - } - }, - - onFormatChanged: (format) { - if (_calendarFormat != format) { - // update settings in local data - BlocProvider.of(context) - .add(ScheduleUpdateSettingsEvent(calendarFormat: format.index)); - - // Call `setState()` when updating calendar format - setState(() { - _calendarFormat = format; - }); - } - }, - onPageChanged: (focusedDay) { - // No need to call `setState()` here - _focusedDay = _validateDayInRange(focusedDay); - }, - onHeaderTapped: (date) { - final currentDate = DateTime.now(); - setState(() { - _focusedDay = _validateDayInRange(currentDate); - _selectedDay = currentDate; - _selectedPage = _selectedDay.difference(_firstCalendarDay).inDays; - _selectedWeek = - CalendarUtils.getCurrentWeek(mCurrentDate: _selectedDay); - _controller.jumpToPage(_selectedPage); - }); - }, - onHeaderLongPressed: (date) { - // set up the AlertDialog - AlertDialog alert = AlertDialog( - contentPadding: const EdgeInsets.fromLTRB(8.0, 20.0, 8.0, 8.0), - backgroundColor: AppTheme.colors.background02, - title: const Text("Выберите неделю"), - content: Wrap( - spacing: 4.0, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - for (int i = 1; i <= CalendarUtils.kMaxWeekInSemester; i++) - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppTheme.colors.primary, - shadowColor: Colors.transparent, - ), - onPressed: () { - setState(() { - if (i == 1) { - _selectedDay = CalendarUtils.getDaysInWeek( - i)[CalendarUtils.getSemesterStart().weekday - 1]; - } else { - _selectedDay = CalendarUtils.getDaysInWeek(i)[0]; - } - - _selectedDay = _selectedDay; - _selectedPage = - _selectedDay.difference(_firstCalendarDay).inDays; - _selectedWeek = i; - _focusedDay = _validateDayInRange(_selectedDay); - _controller.jumpToPage(_selectedPage); - }); - }, - child: Text(i.toString()), - ), - ], - ), - ); - - showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - ); - }, - ); + ); + } } Widget _buildPageView() { return PageView.builder( + key: const PageStorageKey('pageView'), controller: _controller, - physics: const ClampingScrollPhysics(), onPageChanged: (value) { setState(() { // if the pages are moved by swipes @@ -418,6 +225,204 @@ class _SchedulePageViewState extends State { ); } + Widget _buildCalendar(bool isDesktop) { + return Material( + color: AppTheme.colors.background01, + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: TableCalendar( + weekendDays: const [DateTime.sunday], + calendarBuilders: CalendarBuilders( + markerBuilder: (context, day, events) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + events.length, + (index) => Container( + width: 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 0.3), + decoration: BoxDecoration( + color: LessonCard.getColorByType( + (events[index] as Lesson).types), + shape: BoxShape.circle, + ), + ), + ), + ); + }, + ), + calendarFormat: isDesktop ? CalendarFormat.month : _calendarFormat, + firstDay: _firstCalendarDay, + lastDay: _lastCalendarDay, + sixWeekMonthsEnforced: true, + startingDayOfWeek: StartingDayOfWeek.monday, + headerStyle: HeaderStyle( + formatButtonVisible: !isDesktop, + formatButtonShowsNext: false, + titleTextStyle: AppTextStyle.captionL.copyWith( + color: AppTheme.colors.active, + ), + formatButtonTextStyle: AppTextStyle.buttonS.copyWith( + color: AppTheme.colors.active, + ), + titleTextFormatter: (DateTime date, dynamic locale) { + String dateStr = DateFormat.yMMMM(locale).format(date); + String weekStr = _selectedWeek.toString(); + return '$dateStr\nвыбрана $weekStr неделя'; + }, + leftChevronIcon: + Icon(Icons.chevron_left, color: AppTheme.colors.active), + rightChevronIcon: + Icon(Icons.chevron_right, color: AppTheme.colors.active), + formatButtonDecoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: AppTheme.colors.deactive)), + borderRadius: const BorderRadius.all(Radius.circular(16.0))), + ), + calendarStyle: CalendarStyle( + rangeHighlightColor: AppTheme.colors.secondary, + cellAlignment: Alignment.center, + cellMargin: const EdgeInsets.all(10), + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: + AppTextStyle.body.copyWith(color: AppTheme.colors.deactive), + weekendStyle: AppTextStyle.body + .copyWith(color: AppTheme.colors.deactiveDarker), + ), + focusedDay: _focusedDay, + availableCalendarFormats: const { + CalendarFormat.month: 'Месяц', + CalendarFormat.twoWeeks: '2 недели', + CalendarFormat.week: 'Неделя' + }, + eventLoader: (day) { + final int week = CalendarUtils.getCurrentWeek(mCurrentDate: day); + final int weekday = day.weekday - 1; + + final lessons = _getLessonsByWeek(week, widget.schedule); + if (weekday == 6) { + return []; + } else { + final Map uniqueLessonEvents = {}; + + for (var lesson in lessons[weekday]) { + if (uniqueLessonEvents.containsKey(lesson.timeStart)) { + continue; + } + + uniqueLessonEvents[lesson.timeStart] = lesson; + } + + return uniqueLessonEvents.values.toList(); + } + }, + locale: 'ru_RU', + selectedDayPredicate: (day) { + // Use `selectedDayPredicate` to determine which day is currently selected. + // If this returns true, then `day` will be marked as selected. + + // Using `isSameDay` is recommended to disregard + // the time-part of compared DateTime objects. + return isSameDay(_selectedDay, day); + }, + onDaySelected: (selectedDay, focusedDay) { + if (!isSameDay(_selectedDay, selectedDay)) { + final int currentNewWeek = + CalendarUtils.getCurrentWeek(mCurrentDate: selectedDay); + // Call `setState()` when updating the selected day + setState(() { + _selectedWeek = currentNewWeek; + _selectedPage = + selectedDay.difference(_firstCalendarDay).inDays; + _selectedDay = selectedDay; + _focusedDay = _validateDayInRange(focusedDay); + _controller.jumpToPage(_selectedPage); + }); + } + }, + onFormatChanged: (format) { + if (_calendarFormat != format) { + // update settings in local data + BlocProvider.of(context).add( + ScheduleUpdateSettingsEvent(calendarFormat: format.index)); + + // Call `setState()` when updating calendar format + setState(() { + _calendarFormat = format; + }); + } + }, + onPageChanged: (focusedDay) { + // No need to call `setState()` here + _focusedDay = _validateDayInRange(focusedDay); + }, + onHeaderTapped: (date) { + final currentDate = DateTime.now(); + setState(() { + _focusedDay = _validateDayInRange(currentDate); + _selectedDay = currentDate; + _selectedPage = _selectedDay.difference(_firstCalendarDay).inDays; + _selectedWeek = + CalendarUtils.getCurrentWeek(mCurrentDate: _selectedDay); + _controller.jumpToPage(_selectedPage); + }); + }, + onHeaderLongPressed: (date) { + // set up the AlertDialog + AlertDialog alert = AlertDialog( + contentPadding: const EdgeInsets.fromLTRB(8.0, 20.0, 8.0, 8.0), + backgroundColor: AppTheme.colors.background02, + title: const Text("Выберите неделю"), + content: Wrap( + spacing: 4.0, + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + for (int i = 1; i <= CalendarUtils.kMaxWeekInSemester; i++) + ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: AppTheme.colors.primary, + shadowColor: Colors.transparent, + ), + onPressed: () { + setState(() { + if (i == 1) { + _selectedDay = CalendarUtils.getDaysInWeek(i)[ + CalendarUtils.getSemesterStart().weekday - 1]; + } else { + _selectedDay = CalendarUtils.getDaysInWeek(i)[0]; + } + + _selectedDay = _selectedDay; + _selectedPage = + _selectedDay.difference(_firstCalendarDay).inDays; + _selectedWeek = i; + _focusedDay = _validateDayInRange(_selectedDay); + _controller.jumpToPage(_selectedPage); + }); + }, + child: Text(i.toString()), + ), + ], + ), + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + }, + ), + ), + ); + } + List _getActualStories(List stories) { List actualStories = []; for (final story in stories) { @@ -430,40 +435,35 @@ class _SchedulePageViewState extends State { } Widget _buildStories(List stories) { - return ValueListenableBuilder( - valueListenable: _scrollNotifier, - builder: (context, value, child) { - return SizedBox( - height: value > 80 ? 0 : 80 - value, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - itemBuilder: (_, int i) { - if (DateTime.now().compareTo(stories[i].stopShowDate) == -1) { - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Theme.of(context).shadowColor.withOpacity( - AppTheme.themeMode == ThemeMode.dark ? 0.1 : 0.1), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], + return SizedBox( + height: 80, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 24), + itemBuilder: (_, int i) { + if (DateTime.now().compareTo(stories[i].stopShowDate) == -1) { + return Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity( + AppTheme.themeMode == ThemeMode.dark ? 0.1 : 0.1), + blurRadius: 10, + offset: const Offset(0, 4), ), - child: StoryWidget( - stories: stories, - storyIndex: i, - ), - ); - } - return Container(); - }, - separatorBuilder: (_, int i) => const SizedBox(width: 10), - itemCount: stories.length, - ), - ); - }, + ], + ), + child: StoryWidget( + stories: stories, + storyIndex: i, + ), + ); + } + return Container(); + }, + separatorBuilder: (_, int i) => const SizedBox(width: 10), + itemCount: stories.length, + ), ); } @@ -494,13 +494,25 @@ class _SchedulePageViewState extends State { ], ); } else { - return Column( - children: [ - _buildStoriesBuilder(), - _buildCalendar(false), - const SizedBox(height: 25), - Expanded(child: _buildPageView()), + return NestedScrollView( + controller: _nestedScrollViewController, + headerSliverBuilder: (_, __) => [ + SliverAppBar( + pinned: false, + title: const Text( + 'Расписание', + ), + // stories + bottom: PreferredSize( + preferredSize: const Size.fromHeight(80), + child: _buildStoriesBuilder(), + ), + ), + SliverToBoxAdapter( + child: _buildCalendar(false), + ), ], + body: _buildPageView(), ); } }, diff --git a/lib/presentation/pages/schedule/widgets/schedule_settings_modal.dart b/lib/presentation/pages/schedule/widgets/schedule_settings_modal.dart index 262bac1b..7551a4a1 100644 --- a/lib/presentation/pages/schedule/widgets/schedule_settings_modal.dart +++ b/lib/presentation/pages/schedule/widgets/schedule_settings_modal.dart @@ -1,6 +1,5 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/presentation/widgets/keyboard_positioned.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; @@ -78,7 +77,7 @@ class ScheduleSettingsModal extends StatelessWidget { // Close modal Navigator.of(context).pop(); - context.router.push(const GroupsSelectRoute()); + context.go('/schedule/select-group'); }, child: Text( 'Начать', diff --git a/lib/presentation/pages/schedule/widgets/stories_wrapper.dart b/lib/presentation/pages/schedule/widgets/stories_wrapper.dart index fdd5c8ee..e2d45074 100644 --- a/lib/presentation/pages/schedule/widgets/stories_wrapper.dart +++ b/lib/presentation/pages/schedule/widgets/stories_wrapper.dart @@ -1,7 +1,7 @@ -import 'package:auto_route/auto_route.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/common/utils/utils.dart'; import 'package:rtu_mirea_app/domain/entities/story.dart'; import 'package:rtu_mirea_app/presentation/widgets/buttons/primary_button.dart'; @@ -39,7 +39,7 @@ class _StoriesWrapperState extends State { @override Widget build(BuildContext context) { return DismissiblePage( - onDismissed: () => context.router.pop(), + onDismissed: () => context.pop(), isFullScreen: false, direction: DismissiblePageDismissDirection.vertical, child: Center( @@ -168,7 +168,7 @@ class _StoriesWrapperState extends State { ), ), ), - onPressed: () => context.router.pop(), + onPressed: () => context.pop(), ), ), ), @@ -202,7 +202,7 @@ class _StoriesWrapperState extends State { storyLength: (int pageIndex) { return widget.stories[pageIndex].pages.length; }, - onPageLimitReached: () => context.router.pop(), + onPageLimitReached: () => context.pop(), ), ), ), diff --git a/lib/presentation/pages/schedule/widgets/story_item.dart b/lib/presentation/pages/schedule/widgets/story_item.dart index ffadd7a6..67a2d306 100644 --- a/lib/presentation/pages/schedule/widgets/story_item.dart +++ b/lib/presentation/pages/schedule/widgets/story_item.dart @@ -1,7 +1,6 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:rtu_mirea_app/domain/entities/story.dart'; -import 'package:rtu_mirea_app/presentation/core/routes/routes.gr.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; @@ -16,8 +15,7 @@ class StoryWidget extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - context.router.push( - StoriesWrapperRoute(stories: stories, storyIndex: storyIndex)); + context.go('/schedule/story/$storyIndex', extra: stories); }, child: Hero( tag: stories[storyIndex].title, diff --git a/lib/presentation/theme.dart b/lib/presentation/theme.dart index a5ec74c0..e5b15d5c 100644 --- a/lib/presentation/theme.dart +++ b/lib/presentation/theme.dart @@ -1,4 +1,3 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rtu_mirea_app/presentation/colors.dart'; @@ -45,10 +44,6 @@ class AppTheme { selectedLabelStyle: AppTextStyle.captionL, unselectedLabelStyle: AppTextStyle.captionS, ), - pageTransitionsTheme: const PageTransitionsTheme(builders: { - TargetPlatform.iOS: NoShadowCupertinoPageTransitionsBuilder(), - TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), - }), colorScheme: ColorScheme( background: darkThemeColors.background01, brightness: Brightness.dark, @@ -87,10 +82,6 @@ class AppTheme { selectedLabelStyle: AppTextStyle.captionL, unselectedLabelStyle: AppTextStyle.captionS, ), - pageTransitionsTheme: const PageTransitionsTheme(builders: { - TargetPlatform.iOS: NoShadowCupertinoPageTransitionsBuilder(), - TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), - }), colorScheme: ColorScheme( background: lightThemeColors.background01, brightness: Brightness.light, diff --git a/lib/presentation/widgets/page_with_theme_consumer.dart b/lib/presentation/widgets/page_with_theme_consumer.dart new file mode 100644 index 00000000..75fe92ad --- /dev/null +++ b/lib/presentation/widgets/page_with_theme_consumer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:rtu_mirea_app/presentation/app_notifier.dart'; + +abstract class PageWithThemeConsumer extends StatelessWidget { + const PageWithThemeConsumer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appNotifier, child) { + return buildPage(context); + }, + ); + } + + Widget buildPage(BuildContext context); +} diff --git a/lib/service_locator.dart b/lib/service_locator.dart index 30b6d8f2..d3950a7f 100644 --- a/lib/service_locator.dart +++ b/lib/service_locator.dart @@ -1,7 +1,12 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:firebase_notifications_client/firebase_notifications_client.dart'; import 'package:get_it/get_it.dart'; +import 'package:notifications_repository/notifications_repository.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:permission_client/permission_client.dart'; +import 'package:persistent_storage/persistent_storage.dart'; import 'package:rtu_mirea_app/common/oauth.dart'; import 'package:rtu_mirea_app/common/utils/connection_checker.dart'; import 'package:rtu_mirea_app/data/datasources/app_settings_local.dart'; @@ -68,11 +73,13 @@ import 'package:rtu_mirea_app/presentation/bloc/map_cubit/map_cubit.dart'; import 'package:rtu_mirea_app/presentation/bloc/news_bloc/news_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/nfc_feedback_bloc/nfc_feedback_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart'; +import 'package:rtu_mirea_app/presentation/bloc/notification_preferences/notification_preferences_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/schedule_bloc/schedule_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/scores_bloc/scores_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/stories_bloc/stories_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/update_info_bloc/update_info_bloc.dart'; import 'package:rtu_mirea_app/presentation/bloc/user_bloc/user_bloc.dart'; +import 'package:rtu_mirea_app/presentation/core/routes/routes.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -139,6 +146,8 @@ Future setup() async { )); getIt .registerFactory(() => NfcFeedbackBloc(sendNfcNotExistFeedback: getIt())); + getIt.registerFactory( + () => NotificationPreferencesBloc(notificationsRepository: getIt())); // Usecases getIt.registerLazySingleton(() => GetStories(getIt())); @@ -213,6 +222,13 @@ Future setup() async { localDataSource: getIt(), )); + getIt.registerLazySingleton( + () => NotificationsRepository( + permissionClient: getIt(), + storage: getIt(), + notificationsClient: getIt(), + )); + getIt.registerLazySingleton(() => UserLocalDataImpl( sharedPreferences: getIt(), secureStorage: getIt(), @@ -257,4 +273,14 @@ Future setup() async { getIt.registerLazySingleton(() => LksOauth2()); final DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); getIt.registerLazySingleton(() => deviceInfo); + + getIt.registerLazySingleton(() => createRouter()); + getIt.registerLazySingleton(() => FirebaseNotificationsClient( + firebaseMessaging: FirebaseMessaging.instance)); + getIt.registerLazySingleton(() => const PermissionClient()); + getIt.registerLazySingleton( + () => PersistentStorage(sharedPreferences: getIt())); + getIt.registerLazySingleton(() => NotificationsStorage( + storage: getIt(), + )); } diff --git a/packages/news_api_client/.gitignore b/packages/news_api_client/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/news_api_client/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/news_api_client/.metadata b/packages/news_api_client/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/news_api_client/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/news_api_client/analysis_options.yaml b/packages/news_api_client/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/news_api_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/news_api_client/lib/api_client.dart b/packages/news_api_client/lib/api_client.dart new file mode 100644 index 00000000..adf6e2ae --- /dev/null +++ b/packages/news_api_client/lib/api_client.dart @@ -0,0 +1,8 @@ +/// API клиент предоставляет клиентский доступ к удаленному API. +library api_client; + +export 'src/client/news_api_client.dart' + show NewsApiClient, NewsApiMalformedResponse, NewsApiRequestFailure; + +export 'src/models/models.dart' + show NewsResponse; diff --git a/packages/news_api_client/lib/src/client/news_api_client.dart b/packages/news_api_client/lib/src/client/news_api_client.dart new file mode 100644 index 00000000..3fdbe040 --- /dev/null +++ b/packages/news_api_client/lib/src/client/news_api_client.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:news_api_client/api_client.dart'; + +/// {@template schedule_api_malformed_response} +/// Исключение, генерируемое при возникновении проблемы во время обработки тела +/// ответ. +/// {@endtemplate} +class NewsApiMalformedResponse implements Exception { + /// {@macro schedule_api_malformed_response} + const NewsApiMalformedResponse({required this.error}); + + /// Связанная ошибка. + final Object error; +} + +/// {@template schedule_api_request_failure} +/// Исключение, генерируемое при ошибке во время http-запроса. +/// {@endtemplate} +class NewsApiRequestFailure implements Exception { + /// {@macro schedule_api_request_failure} + const NewsApiRequestFailure({ + required this.statusCode, + required this.body, + }); + + /// Связанный http-статус код. + final int statusCode; + + /// Связанное тело ответа. + final Map body; +} + +/// Dart API клиент для Schedule Mirea Ninja API. +class NewsApiClient { + /// Создает экземпляр [NewsApiClient] для интеграции + /// с удаленным API. + NewsApiClient({ + http.Client? httpClient, + }) : this._( + baseUrl: 'https://mirea.ru/api/oCoGUGuMhQzPEDJYF6Qy.php', + httpClient: httpClient, + ); + + /// Создает экземпляр [NewsApiClient] для интеграции + /// с локальным API. + /// + /// Используется для тестирования. + NewsApiClient.localhost({ + http.Client? httpClient, + }) : this._( + baseUrl: 'http://localhost:8080', + httpClient: httpClient, + ); + + NewsApiClient._({ + required String baseUrl, + http.Client? httpClient, + }) : _baseUrl = baseUrl, + _httpClient = httpClient ?? http.Client(); + + final String _baseUrl; + final http.Client _httpClient; + + /// Запрос на получения списка новостей. + /// + /// Доступные параметры: + /// * [page] - номер страницы. Нумерация начинается с 1. + /// * [pageSize] - количество новостей на странице. + /// * [tag] - тег новости. Если не указан, то будут возвращены все новости. + /// * [isImportant] - если `true`, то будут возвращены новости из раздела + /// "Важное", иначе - новости из раздела "Новости". + Future> getNews({ + int? page, + int? pageSize, + String? tag, + bool isImportant = false, + }) async { + final methodUri = !isImportant ? '?method=getNews' : '?method=getAds'; + + final uri = Uri.parse('$_baseUrl$methodUri').replace( + queryParameters: { + if (page != null) 'iNumPage': page.toString(), + if (pageSize != null) 'nPageSize': pageSize.toString(), + if (tag != null) 'tag': tag, + }, + ); + final response = await _httpClient.get( + uri, + headers: await _getRequestHeaders(), + ); + final body = response.json(); + + if (response.statusCode != HttpStatus.ok) { + throw NewsApiRequestFailure( + body: body, + statusCode: response.statusCode, + ); + } + + try { + final rawNews = body['result'] as List>; + + return List.from( + rawNews.map((rawNewsItem) { + return NewsResponse.fromJson(rawNewsItem); + }), + ); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + NewsApiMalformedResponse(error: error), + stackTrace, + ); + } + } + + /// Запрос на получение списка тегов новостей. + /// + /// Доступные параметры: + /// * [tagUsageCount] - количество использований тега. Если не указано, то + /// будут возвращены все теги. + Future> getTags({ + int tagUsageCount = 3, + }) async { + final uri = Uri.parse('$_baseUrl?method=getNewsTags'); + final response = await _httpClient.get( + uri, + headers: await _getRequestHeaders(), + ); + final body = response.json(); + + if (response.statusCode != HttpStatus.ok) { + throw NewsApiRequestFailure( + body: body, + statusCode: response.statusCode, + ); + } + + try { + final rawTags = body['result'] as List>; + + return List.from( + // Используем только те теги, которые были использованы более 3 раз. + // Ключ 'CNT' - количество использований тега. + rawTags + .where( + (rawTag) => int.parse(rawTag['CNT'] as String) > tagUsageCount,) + .map((rawTag) => rawTag['NAME'] as String), + ); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + NewsApiMalformedResponse(error: error), + stackTrace, + ); + } + } + + Future> _getRequestHeaders() async { + return { + HttpHeaders.contentTypeHeader: ContentType.json.value, + HttpHeaders.acceptHeader: ContentType.json.value, + }; + } +} + +/// Расширение для [http.Response], которое позволяет получить тело ответа +/// в виде `Map`. Если тело ответа не может быть преобразовано +/// в `Map`, то будет сгенерировано исключение +/// [NewsApiMalformedResponse]. +extension on http.Response { + Map json() { + try { + return jsonDecode(body) as Map; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + NewsApiMalformedResponse(error: error), + stackTrace, + ); + } + } +} diff --git a/packages/news_api_client/lib/src/models/models.dart b/packages/news_api_client/lib/src/models/models.dart new file mode 100644 index 00000000..77a9dd67 --- /dev/null +++ b/packages/news_api_client/lib/src/models/models.dart @@ -0,0 +1 @@ +export 'news_response/news_response.dart'; diff --git a/packages/news_api_client/lib/src/models/news_response/news_response.dart b/packages/news_api_client/lib/src/models/news_response/news_response.dart new file mode 100644 index 00000000..72c199e7 --- /dev/null +++ b/packages/news_api_client/lib/src/models/news_response/news_response.dart @@ -0,0 +1,67 @@ +import 'package:intl/intl.dart'; +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'news_response.g.dart'; + +/// {@template news_response} +/// Ответ на запрос новости +/// {@endtemplate} +@JsonSerializable() +class NewsResponse extends Equatable { + /// {@macro news_response} + const NewsResponse({ + required this.title, + required this.text, + required this.date, + required this.images, + required this.tags, + required this.coverImage, + }); + + /// Конвертирует `Map` в [NewsResponse] + factory NewsResponse.fromJson(Map json) => + _$NewsResponseFromJson(json); + + /// Заголовок новости. + @JsonKey(name: 'NAME') + final String title; + + /// Текст новости. Может содержать HTML-теги. + @JsonKey(name: 'DETAIL_TEXT') + final String text; + + static DateTime _dateFromJson(String date) => + DateFormat('dd.MM.yyyy').parse(date); + + static String _dateToJson(DateTime date) => + DateFormat('dd.MM.yyyy').format(date); + + /// Дата публикации новости. + @JsonKey( + name: 'DATE_ACTIVE_FROM', fromJson: _dateFromJson, toJson: _dateToJson,) + final DateTime date; + + /// Ссылки на изображения новости. + @JsonKey(name: 'PROPERTY_MY_GALLERY_VALUE') + final List images; + + /// Обложка новости. + @JsonKey(name: 'DETAIL_PICTURE') + final String coverImage; + + static List _tagsFromJson(String tags) => + tags.split(',').map((e) => e.trim()).toList(); + + static String _tagsToJson(List tags) => tags.join(','); + + /// Теги новости + @JsonKey(fromJson: _tagsFromJson, name: 'TAGS', toJson: _tagsToJson) + final List tags; + + /// Конвертирует [NewsResponse] в `Map` + Map toJson() => _$NewsResponseToJson(this); + + @override + List get props => [title, text, date, images, tags]; +} diff --git a/packages/news_api_client/lib/src/models/news_response/news_response.g.dart b/packages/news_api_client/lib/src/models/news_response/news_response.g.dart new file mode 100644 index 00000000..ecf58004 --- /dev/null +++ b/packages/news_api_client/lib/src/models/news_response/news_response.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'news_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +NewsResponse _$NewsResponseFromJson(Map json) => NewsResponse( + title: json['NAME'] as String, + text: json['DETAIL_TEXT'] as String, + date: NewsResponse._dateFromJson(json['DATE_ACTIVE_FROM'] as String), + images: (json['PROPERTY_MY_GALLERY_VALUE'] as List) + .map((e) => e as String) + .toList(), + tags: NewsResponse._tagsFromJson(json['TAGS'] as String), + coverImage: json['DETAIL_PICTURE'] as String, + ); + +Map _$NewsResponseToJson(NewsResponse instance) => + { + 'NAME': instance.title, + 'DETAIL_TEXT': instance.text, + 'DATE_ACTIVE_FROM': NewsResponse._dateToJson(instance.date), + 'PROPERTY_MY_GALLERY_VALUE': instance.images, + 'DETAIL_PICTURE': instance.coverImage, + 'TAGS': NewsResponse._tagsToJson(instance.tags), + }; diff --git a/packages/news_api_client/pubspec.yaml b/packages/news_api_client/pubspec.yaml new file mode 100644 index 00000000..5f3d26d0 --- /dev/null +++ b/packages/news_api_client/pubspec.yaml @@ -0,0 +1,21 @@ +name: news_api_client +description: A new Flutter package project. +version: 0.0.1 +homepage: + +environment: + sdk: '>=3.1.2 <4.0.0' + +dependencies: + collection: ^1.18.0 + equatable: ^2.0.5 + http: ^1.1.0 + json_annotation: ^4.8.1 + intl: ^0.18.1 + +dev_dependencies: + build_runner: ^2.4.6 + json_serializable: ^6.7.1 + mocktail: ^1.0.0 + test: ^1.24.6 + very_good_analysis: ^5.1.0 \ No newline at end of file diff --git a/packages/news_api_client/test/src/clients/news_api_client_test.dart b/packages/news_api_client/test/src/clients/news_api_client_test.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/news_api_client/test/src/clients/news_api_client_test.dart @@ -0,0 +1 @@ + diff --git a/packages/news_api_client/test/src/models/news_test.dart b/packages/news_api_client/test/src/models/news_test.dart new file mode 100644 index 00000000..305b71b0 --- /dev/null +++ b/packages/news_api_client/test/src/models/news_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +import 'package:news_api_client/api_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('News', () { + test('can be (de)serialized', () { + final news = NewsResponse( + title: 'title', + text: 'text', + coverImage: 'https://localhost/png.png', + images: const ['1', 'qwe', 'asd'], + date: DateTime(2023, 1, 9), + tags: const [ + 'hello', + 'world', + ], + ); + + expect(NewsResponse.fromJson(news.toJson()), equals(news)); + }); + + test('can be deserialized from raw text', () { + const raw = ''' + { + "NAME": "Test title", + "DATE_ACTIVE_FROM": "19.09.2023", + "DETAIL_TEXT": "Hello world", + "DETAIL_PICTURE": "https://www.mirea.ru/IMG.jpeg", + "TAGS": "студентам, спорт, достижения Университета", + "PROPERTY_MY_GALLERY_VALUE": [ + "https://www.mirea.ru/IMG.jpeg" + ], + "ACTIVE_FROM": "19.09.2023" + } + '''; + + final news = NewsResponse( + title: 'Test title', + text: 'Hello world', + coverImage: 'https://www.mirea.ru/IMG.jpeg', + images: const ['https://www.mirea.ru/IMG.jpeg'], + date: DateTime(2023, 9, 19), + tags: const ['студентам', 'спорт', 'достижения Университета'], + ); + + expect( + NewsResponse.fromJson(jsonDecode(raw) as Map), + equals(news), + ); + }); + }); +} diff --git a/packages/notifications_client/firebase_notifications_client/.gitignore b/packages/notifications_client/firebase_notifications_client/.gitignore new file mode 100644 index 00000000..d6130351 --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/notifications_client/firebase_notifications_client/README.md b/packages/notifications_client/firebase_notifications_client/README.md new file mode 100644 index 00000000..cb4094a1 --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/README.md @@ -0,0 +1,5 @@ +# firebase_notifications_client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +Клиент для работы с Firebase Cloud Messaging. diff --git a/packages/notifications_client/firebase_notifications_client/analysis_options.yaml b/packages/notifications_client/firebase_notifications_client/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/notifications_client/firebase_notifications_client/lib/firebase_notifications_client.dart b/packages/notifications_client/firebase_notifications_client/lib/firebase_notifications_client.dart new file mode 100644 index 00000000..9cb0ef87 --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/lib/firebase_notifications_client.dart @@ -0,0 +1 @@ +export 'src/firebase_notifications_client.dart'; diff --git a/packages/notifications_client/firebase_notifications_client/lib/src/firebase_notifications_client.dart b/packages/notifications_client/firebase_notifications_client/lib/src/firebase_notifications_client.dart new file mode 100644 index 00000000..96fe2d6b --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/lib/src/firebase_notifications_client.dart @@ -0,0 +1,38 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:notifications_client/notifications_client.dart'; + +/// {@template firebase_notifications_client} +/// Клиенте для работы с уведомлениями на основе Firebase. +/// {@endtemplate} +class FirebaseNotificationsClient implements NotificationsClient { + /// {@macro firebase_notifications_client} + const FirebaseNotificationsClient({ + required FirebaseMessaging firebaseMessaging, + }) : _firebaseMessaging = firebaseMessaging; + + final FirebaseMessaging _firebaseMessaging; + + @override + Future subscribeToCategory(String category) async { + try { + await _firebaseMessaging.subscribeToTopic(category); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + SubscribeToCategoryFailure(error), + stackTrace, + ); + } + } + + @override + Future unsubscribeFromCategory(String category) async { + try { + await _firebaseMessaging.unsubscribeFromTopic(category); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + UnsubscribeFromCategoryFailure(error), + stackTrace, + ); + } + } +} diff --git a/packages/notifications_client/firebase_notifications_client/pubspec.yaml b/packages/notifications_client/firebase_notifications_client/pubspec.yaml new file mode 100644 index 00000000..8fe28003 --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/pubspec.yaml @@ -0,0 +1,21 @@ +name: firebase_notifications_client +description: A Firebase Cloud Messaging notifications client. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + +dependencies: + firebase_messaging: ^14.0.3 + flutter: + sdk: flutter + notifications_client: + path: ../notifications_client + +dev_dependencies: + test: ^1.24.3 + flutter_test: + sdk: flutter + mocktail: ^1.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/notifications_client/firebase_notifications_client/test/src/firebase_notifications_client_test.dart b/packages/notifications_client/firebase_notifications_client/test/src/firebase_notifications_client_test.dart new file mode 100644 index 00000000..4857ddca --- /dev/null +++ b/packages/notifications_client/firebase_notifications_client/test/src/firebase_notifications_client_test.dart @@ -0,0 +1,75 @@ +// ignore_for_file: prefer_const_constructors +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:firebase_notifications_client/firebase_notifications_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:notifications_client/notifications_client.dart'; + +class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} + +void main() { + group('FirebaseNotificationsClient', () { + late FirebaseMessaging firebaseMessaging; + late FirebaseNotificationsClient firebaseNotificationsClient; + + const category = 'category'; + + setUp(() { + firebaseMessaging = MockFirebaseMessaging(); + firebaseNotificationsClient = FirebaseNotificationsClient( + firebaseMessaging: firebaseMessaging, + ); + }); + + group('when FirebaseNotificationClient.subscribeToCategory called', () { + test('calls FirebaseMessaging.subscribeToTopic', () async { + when( + () => firebaseMessaging.subscribeToTopic(category), + ).thenAnswer((_) async {}); + + await firebaseNotificationsClient.subscribeToCategory(category); + + verify(() => firebaseMessaging.subscribeToTopic(category)).called(1); + }); + + test( + 'throws SubscribeToCategoryFailure ' + 'when FirebaseMessaging.subscribeToTopic fails', () async { + when( + () => firebaseMessaging.subscribeToTopic(category), + ).thenAnswer((_) async => throw Exception()); + + expect( + () => firebaseNotificationsClient.subscribeToCategory(category), + throwsA(isA()), + ); + }); + }); + + group('when FirebaseNotificationClient.unsubscribeFromCategory called', () { + test('calls FirebaseMessaging.unsubscribeFromTopic', () async { + when( + () => firebaseMessaging.unsubscribeFromTopic(category), + ).thenAnswer((_) async {}); + + await firebaseNotificationsClient.unsubscribeFromCategory(category); + + verify(() => firebaseMessaging.unsubscribeFromTopic(category)) + .called(1); + }); + + test( + 'throws UnsubscribeFromCategoryFailure ' + 'when FirebaseMessaging.unsubscribeFromTopic fails', () async { + when( + () => firebaseMessaging.unsubscribeFromTopic(category), + ).thenAnswer((_) async => throw Exception()); + + expect( + () => firebaseNotificationsClient.unsubscribeFromCategory(category), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/notifications_client/notifications_client/.gitignore b/packages/notifications_client/notifications_client/.gitignore new file mode 100644 index 00000000..d6130351 --- /dev/null +++ b/packages/notifications_client/notifications_client/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/notifications_client/notifications_client/README.md b/packages/notifications_client/notifications_client/README.md new file mode 100644 index 00000000..28ab9404 --- /dev/null +++ b/packages/notifications_client/notifications_client/README.md @@ -0,0 +1,6 @@ +# notifications_client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A Generic Notifications Client Interface. diff --git a/packages/notifications_client/notifications_client/analysis_options.yaml b/packages/notifications_client/notifications_client/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/notifications_client/notifications_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/notifications_client/notifications_client/lib/notifications_client.dart b/packages/notifications_client/notifications_client/lib/notifications_client.dart new file mode 100644 index 00000000..209b146b --- /dev/null +++ b/packages/notifications_client/notifications_client/lib/notifications_client.dart @@ -0,0 +1 @@ +export 'src/notifications_client.dart'; diff --git a/packages/notifications_client/notifications_client/lib/src/notifications_client.dart b/packages/notifications_client/notifications_client/lib/src/notifications_client.dart new file mode 100644 index 00000000..4e958972 --- /dev/null +++ b/packages/notifications_client/notifications_client/lib/src/notifications_client.dart @@ -0,0 +1,37 @@ +/// {@template notification_exception} +/// Исключение клиента уведомлений. +/// {@endtemplate} +abstract class NotificationException implements Exception { + /// {@macro notification_exception} + const NotificationException(this.error); + + /// Связанная ошибка. + final Object error; +} + +/// {@template subscribe_to_category_failure} +/// Выбрасывается при ошибке подписки на категорию. +/// {@endtemplate} +class SubscribeToCategoryFailure extends NotificationException { + /// {@macro subscribe_to_category_failure} + const SubscribeToCategoryFailure(super.error); +} + +/// {@template unsubscribe_from_category_failure} +/// Выбрасывается при ошибке отписки от категории. +/// {@endtemplate} +class UnsubscribeFromCategoryFailure extends NotificationException { + /// {@macro unsubscribe_from_category_failure} + const UnsubscribeFromCategoryFailure(super.error); +} + +/// {@template notifications_client} +/// Клиент для работы с уведомлениями. +/// {@endtemplate} +abstract class NotificationsClient { + /// Подписывает пользователя на группу уведомлений на основе [category]. + Future subscribeToCategory(String category); + + /// Отписывает пользователя от группы уведомлений на основе [category]. + Future unsubscribeFromCategory(String category); +} diff --git a/packages/notifications_client/notifications_client/pubspec.yaml b/packages/notifications_client/notifications_client/pubspec.yaml new file mode 100644 index 00000000..9b068196 --- /dev/null +++ b/packages/notifications_client/notifications_client/pubspec.yaml @@ -0,0 +1,11 @@ +name: notifications_client +description: A Generic Notifications Client Interface. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + +dev_dependencies: + test: ^1.24.6 + very_good_analysis: ^5.1.0 diff --git a/packages/notifications_client/notifications_client/test/src/notifications_client_test.dart b/packages/notifications_client/notifications_client/test/src/notifications_client_test.dart new file mode 100644 index 00000000..28a64c41 --- /dev/null +++ b/packages/notifications_client/notifications_client/test/src/notifications_client_test.dart @@ -0,0 +1,27 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:notifications_client/notifications_client.dart'; +import 'package:test/fake.dart'; +import 'package:test/test.dart'; + +class FakeNotificationsClient extends Fake implements NotificationsClient {} + +void main() { + test('NotificationsClient can be implemented', () { + expect(FakeNotificationsClient.new, returnsNormally); + }); + + test('exports SubscribeToCategoryFailure', () { + expect( + () => SubscribeToCategoryFailure('oops'), + returnsNormally, + ); + }); + + test('exports UnsubscribeFromCategoryFailure', () { + expect( + () => UnsubscribeFromCategoryFailure('oops'), + returnsNormally, + ); + }); +} diff --git a/packages/notifications_repository/.gitignore b/packages/notifications_repository/.gitignore new file mode 100644 index 00000000..526da158 --- /dev/null +++ b/packages/notifications_repository/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/notifications_repository/README.md b/packages/notifications_repository/README.md new file mode 100644 index 00000000..22afee17 --- /dev/null +++ b/packages/notifications_repository/README.md @@ -0,0 +1,6 @@ +# notifications_repository + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + + +Репозиторий, который управляет разрешениями на уведомления и подписками на топики. \ No newline at end of file diff --git a/packages/notifications_repository/analysis_options.yaml b/packages/notifications_repository/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/notifications_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/notifications_repository/lib/notifications_repository.dart b/packages/notifications_repository/lib/notifications_repository.dart new file mode 100644 index 00000000..862d9364 --- /dev/null +++ b/packages/notifications_repository/lib/notifications_repository.dart @@ -0,0 +1 @@ +export 'src/notifications_repository.dart'; diff --git a/packages/notifications_repository/lib/src/notifications_repository.dart b/packages/notifications_repository/lib/src/notifications_repository.dart new file mode 100644 index 00000000..a09958f1 --- /dev/null +++ b/packages/notifications_repository/lib/src/notifications_repository.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:notifications_client/notifications_client.dart'; +import 'package:permission_client/permission_client.dart'; +import 'package:storage/storage.dart'; + +part 'notifications_storage.dart'; + +/// {@template notifications_failure} +/// Базовое исключение для ошибок репозитория уведомлений. +/// {@endtemplate} +abstract class NotificationsFailure with EquatableMixin implements Exception { + /// {@macro notifications_failure} + const NotificationsFailure(this.error); + + /// The error which was caught. + final Object error; + + @override + List get props => [error]; +} + +/// {@template initialize_categories_preferences_failure} +/// Возникает при ошибке инициализации настроек категорий. +/// {@endtemplate} +class InitializeCategoriesPreferencesFailure extends NotificationsFailure { + /// {@macro initialize_categories_preferences_failure} + const InitializeCategoriesPreferencesFailure(super.error); +} + +/// {@template toggle_notifications_failure} +/// Возникает при ошибке включения или отключения уведомлений. +/// {@endtemplate} +class ToggleNotificationsFailure extends NotificationsFailure { + /// {@macro toggle_notifications_failure} + const ToggleNotificationsFailure(super.error); +} + +/// {@template fetch_notifications_enabled_failure} +/// Возникает при ошибке получения статуса включенности уведомлений. +/// {@endtemplate} +class FetchNotificationsEnabledFailure extends NotificationsFailure { + /// {@macro fetch_notifications_enabled_failure} + const FetchNotificationsEnabledFailure(super.error); +} + +/// {@template set_categories_preferences_failure} +/// Возникает при ошибке установки настроек категорий. +/// {@endtemplate} +class SetCategoriesPreferencesFailure extends NotificationsFailure { + /// {@macro set_categories_preferences_failure} + const SetCategoriesPreferencesFailure(super.error); +} + +/// {@template fetch_categories_preferences_failure} +/// Возникает при ошибке получения настроек категорий. +/// {@endtemplate} +class FetchCategoriesPreferencesFailure extends NotificationsFailure { + /// {@macro fetch_categories_preferences_failure} + const FetchCategoriesPreferencesFailure(super.error); +} + +/// {@template notifications_repository} +/// Репозиторий, управляющий разрешениями на уведомления и подписками на топики. +/// +/// Доступ к уведомлениям устройства может быть включен или отключен с помощью +/// [toggleNotifications] и получен с помощью [fetchNotificationsEnabled]. +/// +/// Настройки уведомлений для подписок на топики, связанные с категориями +/// новостей, могут быть обновлены с помощью [setCategoriesPreferences] и +/// получены с помощью [fetchCategoriesPreferences]. +/// {@endtemplate} +class NotificationsRepository { + /// {@macro notifications_repository} + NotificationsRepository({ + required PermissionClient permissionClient, + required NotificationsStorage storage, + required NotificationsClient notificationsClient, + }) : _permissionClient = permissionClient, + _storage = storage, + _notificationsClient = notificationsClient; + + final PermissionClient _permissionClient; + final NotificationsStorage _storage; + final NotificationsClient _notificationsClient; + + /// Включает или отключает уведомления в зависимости от значения [enable]. + /// + /// Если [enable] равно `true`, то запрашивает разрешение на уведомления, если + /// оно ещё не предоставлено, и помечает настройку уведомлений как включенную + /// в [NotificationsStorage]. + /// Подписывает пользователя на уведомления, связанные выбранными категориями. + /// + /// Если [enable] равно `false`, помечает настройку уведомлений как + /// отключенную и отписывает пользователя от уведомлений, связанных выбранными + /// категориями. + Future toggleNotifications({required bool enable}) async { + try { + // Запрашивает разрешение на уведомления, если оно ещё не предоставлено. + if (enable) { + // Получение текущего статуса разрешения на уведомления. + final permissionStatus = await _permissionClient.notificationsStatus(); + + // Открывает настройки разрешений, если разрешение на уведомления + // запрещено или ограничено. + if (permissionStatus.isPermanentlyDenied || + permissionStatus.isRestricted) { + await _permissionClient.openPermissionSettings(); + return; + } + + // Запрашивает разрешение, если уведомления запрещены. + if (permissionStatus.isDenied) { + final updatedPermissionStatus = + await _permissionClient.requestNotifications(); + if (!updatedPermissionStatus.isGranted) { + return; + } + } + } + + // Подписывает пользователя на уведомления, связанные выбранными + // категориями. + await _toggleCategoriesPreferencesSubscriptions(enable: enable); + + // Обновляет настройку уведомлений в Storage. + await _storage.setNotificationsEnabled(enabled: enable); + } catch (error, stackTrace) { + Error.throwWithStackTrace(ToggleNotificationsFailure(error), stackTrace); + } + } + + /// Возвращает `true`, если разрешение на уведомления предоставлено и + /// настройка уведомлений включена. + Future fetchNotificationsEnabled() async { + try { + final results = await Future.wait([ + _permissionClient.notificationsStatus(), + _storage.fetchNotificationsEnabled(), + ]); + + final permissionStatus = results.first as PermissionStatus; + final notificationsEnabled = results.last as bool; + + return permissionStatus.isGranted && notificationsEnabled; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FetchNotificationsEnabledFailure(error), + stackTrace, + ); + } + } + + /// Обновляет настройки пользователя по уведомлениям и подписывает + /// пользователя на получение уведомлений, связанных с [categories]. + /// + /// [categories] представляет собой набор категорий (топиков), по которым + /// пользователь будет получать уведомления. Топиком может быть, например, + /// академическая группа студента или группа новостей. + /// + /// Выбрасывает [SetCategoriesPreferencesFailure], когда не удалось обновить + /// данные. + Future setCategoriesPreferences(Set categories) async { + try { + // Выключает подписки на уведомления для предыдущих настроек категорий. + await _toggleCategoriesPreferencesSubscriptions(enable: false); + + // Обновляет настройки категорий в Storage. + await _storage.setCategoriesPreferences(categories: categories); + + // Подписывает пользователя на уведомления для обновленных настроек + // категорий. + if (await fetchNotificationsEnabled()) { + await _toggleCategoriesPreferencesSubscriptions(enable: true); + } + } catch (error, stackTrace) { + Error.throwWithStackTrace( + SetCategoriesPreferencesFailure(error), + stackTrace, + ); + } + } + + /// Получает настройки пользователя по уведомлениям для категорий. + /// + /// Результат представляет собой набор категорий, на которые пользователь + /// подписался для уведомлений. + /// + /// Выбрасывает [FetchCategoriesPreferencesFailure], когда не удалось получить + /// данные. + Future?> fetchCategoriesPreferences() async { + try { + return await _storage.fetchCategoriesPreferences(); + } on StorageException catch (error, stackTrace) { + Error.throwWithStackTrace( + FetchCategoriesPreferencesFailure(error), + stackTrace, + ); + } + } + + /// Включает или отключает подписки на уведомления в зависимости от + /// настроек пользователя. + Future _toggleCategoriesPreferencesSubscriptions({ + required bool enable, + }) async { + final categoriesPreferences = + await _storage.fetchCategoriesPreferences() ?? {}; + await Future.wait( + categoriesPreferences.map((category) { + return enable + ? _notificationsClient.subscribeToCategory(category) + : _notificationsClient.unsubscribeFromCategory(category); + }), + ); + } +} diff --git a/packages/notifications_repository/lib/src/notifications_storage.dart b/packages/notifications_repository/lib/src/notifications_storage.dart new file mode 100644 index 00000000..c2c020e9 --- /dev/null +++ b/packages/notifications_repository/lib/src/notifications_storage.dart @@ -0,0 +1,65 @@ +part of 'notifications_repository.dart'; + +/// Ключи для [NotificationsStorage]. +abstract class NotificationsStorageKeys { + /// Ключ для хранения статуса включенности уведомлений. + static const notificationsEnabled = '__notifications_enabled_storage_key__'; + + /// Ключ для хранения предпочтений категорий. + static const categoriesPreferences = '__categories_preferences_storage_key__'; +} + +/// {@template notifications_storage} +/// Хранилище для [NotificationsRepository]. +/// {@endtemplate} +class NotificationsStorage { + /// {@macro notifications_storage} + const NotificationsStorage({ + required Storage storage, + }) : _storage = storage; + + final Storage _storage; + + /// Устанавливает статус включенности уведомлений ([enabled]) в хранилище. + Future setNotificationsEnabled({required bool enabled}) => + _storage.write( + key: NotificationsStorageKeys.notificationsEnabled, + value: enabled.toString(), + ); + + /// Получает статус включенности уведомлений из хранилища. + Future fetchNotificationsEnabled() async => + (await _storage.read(key: NotificationsStorageKeys.notificationsEnabled)) + ?.parseBool() ?? + false; + + /// Устанавливает предпочтения категорий в [categories] в хранилище. + Future setCategoriesPreferences({ + required Set categories, + }) async { + final categoriesEncoded = json.encode( + categories.map((category) => category).toList(), + ); + await _storage.write( + key: NotificationsStorageKeys.categoriesPreferences, + value: categoriesEncoded, + ); + } + + /// Получает значение предпочтений категорий из хранилища. + Future?> fetchCategoriesPreferences() async { + final categories = await _storage.read( + key: NotificationsStorageKeys.categoriesPreferences, + ); + if (categories == null) { + return null; + } + return List.from(json.decode(categories) as List).toSet(); + } +} + +extension _BoolFromStringParsing on String { + bool parseBool() { + return toLowerCase() == 'true'; + } +} diff --git a/packages/notifications_repository/pubspec.yaml b/packages/notifications_repository/pubspec.yaml new file mode 100644 index 00000000..2a41977b --- /dev/null +++ b/packages/notifications_repository/pubspec.yaml @@ -0,0 +1,25 @@ +name: notifications_repository +description: A repository that manages notification permissions and topic subscriptions. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + +dependencies: + equatable: ^2.0.5 + flutter: + sdk: flutter + notifications_client: + path: ../notifications_client/notifications_client + permission_client: + path: ../permission_client + storage: + path: ../storage/storage + test: ^1.24.3 + +dev_dependencies: + mocktail: ^1.0.0 + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/packages/notifications_repository/test/src/notifications_repository_test.dart b/packages/notifications_repository/test/src/notifications_repository_test.dart new file mode 100644 index 00000000..365aca30 --- /dev/null +++ b/packages/notifications_repository/test/src/notifications_repository_test.dart @@ -0,0 +1,534 @@ +// ignore_for_file: prefer_const_constructors +// ignore_for_file: prefer_const_literals_to_create_immutables + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:notifications_client/notifications_client.dart'; +import 'package:notifications_repository/notifications_repository.dart'; +import 'package:permission_client/permission_client.dart'; +import 'package:storage/storage.dart'; + +class MockPermissionClient extends Mock implements PermissionClient {} + +class MockNotificationsStorage extends Mock implements NotificationsStorage {} + +class MockNotificationsClient extends Mock implements NotificationsClient {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('NotificationsRepository', () { + late PermissionClient permissionClient; + late NotificationsStorage storage; + late NotificationsClient notificationsClient; + + setUp(() { + permissionClient = MockPermissionClient(); + storage = MockNotificationsStorage(); + notificationsClient = MockNotificationsClient(); + + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.denied); + + when( + () => storage.setNotificationsEnabled( + enabled: any(named: 'enabled'), + ), + ).thenAnswer((_) async {}); + + when( + () => storage.setCategoriesPreferences( + categories: any(named: 'categories'), + ), + ).thenAnswer((_) async {}); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => false); + + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => {'ИКБО-30-20'}); + + when(() => notificationsClient.subscribeToCategory(any())) + .thenAnswer((_) async {}); + when(() => notificationsClient.unsubscribeFromCategory(any())) + .thenAnswer((_) async {}); + }); + + group('constructor', () { + test( + 'initializes categories preferences ' + 'from DailyGlobeApiClient.getCategories', () async { + when(storage.fetchCategoriesPreferences).thenAnswer((_) async => null); + + final completer = Completer(); + const categories = ['ИКБО-40-20', 'ИКБО-30-20']; + + when( + () => storage.setCategoriesPreferences( + categories: any(named: 'categories'), + ), + ).thenAnswer((_) async => completer.complete()); + + final _ = NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ); + + await expectLater(completer.future, completes); + + verify( + () => storage.setCategoriesPreferences( + categories: categories.toSet(), + ), + ).called(1); + }); + + test( + 'throws an InitializeCategoriesPreferencesFailure ' + 'when initialization fails', () async { + Object? caughtError; + await runZonedGuarded(() async { + when(storage.fetchCategoriesPreferences).thenThrow(Exception()); + + final _ = NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ); + }, (error, stackTrace) { + caughtError = error; + }); + + expect( + caughtError, + isA(), + ); + }); + }); + + group('toggleNotifications', () { + group('when enable is true', () { + test( + 'calls openPermissionSettings on PermissionClient ' + 'when PermissionStatus is permanentlyDenied', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.permanentlyDenied); + + when(permissionClient.openPermissionSettings) + .thenAnswer((_) async => true); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true); + + verify(permissionClient.openPermissionSettings).called(1); + }); + + test( + 'calls openPermissionSettings on PermissionClient ' + 'when PermissionStatus is restricted', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.restricted); + + when(permissionClient.openPermissionSettings) + .thenAnswer((_) async => true); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true); + + verify(permissionClient.openPermissionSettings).called(1); + }); + + test( + 'calls requestNotifications on PermissionClient ' + 'when PermissionStatus is denied', () async { + when(permissionClient.requestNotifications) + .thenAnswer((_) async => PermissionStatus.granted); + + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.denied); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true); + + verify(permissionClient.requestNotifications).called(1); + }); + + test('subscribes to categories preferences', () async { + const categoriesPreferences = { + 'ИКБО-30-20', + 'ИКБО-40-20', + }; + + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => categoriesPreferences); + + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.granted); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true); + + for (final category in categoriesPreferences) { + verify(() => notificationsClient.subscribeToCategory(category)) + .called(1); + } + }); + + test( + 'calls setNotificationsEnabled with true ' + 'on NotificationsStorage', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.granted); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true); + + verify( + () => storage.setNotificationsEnabled(enabled: true), + ).called(1); + }); + }); + + group('when enabled is false', () { + test('unsubscribes from categories preferences', () async { + const categoriesPreferences = { + 'ИКБО-30-20', + 'ИКБО-40-20', + }; + + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => categoriesPreferences); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: false); + + for (final category in categoriesPreferences) { + verify( + () => notificationsClient.unsubscribeFromCategory(category), + ).called(1); + } + }); + + test( + 'calls setNotificationsEnabled with false ' + 'on NotificationsStorage', () async { + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: false); + + verify( + () => storage.setNotificationsEnabled(enabled: false), + ).called(1); + }); + }); + + test( + 'throws a ToggleNotificationsFailure ' + 'when toggling notifications fails', () async { + when(permissionClient.notificationsStatus).thenThrow(Exception()); + + expect( + () => NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).toggleNotifications(enable: true), + throwsA(isA()), + ); + }); + }); + + group('fetchNotificationsEnabled', () { + test( + 'returns true ' + 'when the notification permission is granted ' + 'and the notification setting is enabled', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.granted); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => true); + + final result = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchNotificationsEnabled(); + + expect(result, isTrue); + }); + + test( + 'returns false ' + 'when the notification permission is not granted ' + 'and the notification setting is enabled', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.denied); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => true); + + final result = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchNotificationsEnabled(); + + expect(result, isFalse); + }); + + test( + 'returns false ' + 'when the notification permission is not granted ' + 'and the notification setting is disabled', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.denied); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => false); + + final result = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchNotificationsEnabled(); + + expect(result, isFalse); + }); + + test( + 'returns false ' + 'when the notification permission is granted ' + 'and the notification setting is disabled', () async { + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.granted); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => false); + + final result = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchNotificationsEnabled(); + + expect(result, isFalse); + }); + + test( + 'throws a FetchNotificationsEnabledFailure ' + 'when fetching notifications enabled fails', () async { + when(permissionClient.notificationsStatus).thenThrow(Exception()); + + expect( + NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchNotificationsEnabled(), + throwsA(isA()), + ); + }); + }); + + group('setCategoriesPreferences', () { + const categoriesPreferences = { + 'ИКБО-30-20', + 'ИКБО-40-20', + }; + + test('calls setCategoriesPreferences on NotificationsStorage', () async { + when( + () => storage.setCategoriesPreferences( + categories: any(named: 'categories'), + ), + ).thenAnswer((_) async {}); + + await expectLater( + NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).setCategoriesPreferences(categoriesPreferences), + completes, + ); + + verify( + () => storage.setCategoriesPreferences( + categories: categoriesPreferences, + ), + ).called(1); + }); + + test('unsubscribes from previous categories preferences', () async { + const previousCategoriesPreferences = { + 'ИКБО-30-20', + 'ИКБО-40-20', + }; + + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => previousCategoriesPreferences); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).setCategoriesPreferences(categoriesPreferences); + + for (final category in previousCategoriesPreferences) { + verify( + () => notificationsClient.unsubscribeFromCategory(category), + ).called(1); + } + }); + + test( + 'subscribes to categories preferences ' + 'when notifications are enabled', () async { + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => categoriesPreferences); + + when(storage.fetchNotificationsEnabled).thenAnswer((_) async => true); + + when(permissionClient.notificationsStatus) + .thenAnswer((_) async => PermissionStatus.granted); + + await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).setCategoriesPreferences(categoriesPreferences); + + for (final category in categoriesPreferences) { + verify(() => notificationsClient.subscribeToCategory(category)) + .called(1); + } + }); + + test( + 'throws a SetCategoriesPreferencesFailure ' + 'when setting categories preferences fails', () async { + when( + () => storage.setCategoriesPreferences( + categories: any(named: 'categories'), + ), + ).thenThrow(Exception()); + + expect( + NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).setCategoriesPreferences(categoriesPreferences), + throwsA(isA()), + ); + }); + }); + + group('fetchCategoriesPreferences', () { + const categoriesPreferences = { + 'ИКБО-30-20', + 'ИКБО-40-20', + }; + + test('returns categories preferences from NotificationsStorage', + () async { + when(storage.fetchCategoriesPreferences) + .thenAnswer((_) async => categoriesPreferences); + + final actualPreferences = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchCategoriesPreferences(); + + expect(actualPreferences, equals(categoriesPreferences)); + }); + + test( + 'returns null ' + 'when categories preferences do not exist in NotificationsStorage', + () async { + when(storage.fetchCategoriesPreferences).thenAnswer((_) async => null); + + final preferences = await NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ).fetchCategoriesPreferences(); + + expect(preferences, isNull); + }); + + test( + 'throws a FetchCategoriesPreferencesFailure ' + 'when read fails', () async { + final notificationsRepository = NotificationsRepository( + permissionClient: permissionClient, + storage: storage, + notificationsClient: notificationsClient, + ); + + when(storage.fetchCategoriesPreferences) + .thenThrow(StorageException(Error())); + + expect( + notificationsRepository.fetchCategoriesPreferences, + throwsA(isA()), + ); + }); + }); + }); + + group('NotificationsFailure', () { + final error = Exception('errorMessage'); + + group('InitializeCategoriesPreferencesFailure', () { + test('has correct props', () { + expect(InitializeCategoriesPreferencesFailure(error).props, [error]); + }); + }); + + group('ToggleNotificationsFailure', () { + test('has correct props', () { + expect(ToggleNotificationsFailure(error).props, [error]); + }); + }); + + group('FetchNotificationsEnabledFailure', () { + test('has correct props', () { + expect(FetchNotificationsEnabledFailure(error).props, [error]); + }); + }); + + group('SetCategoriesPreferencesFailure', () { + test('has correct props', () { + expect(SetCategoriesPreferencesFailure(error).props, [error]); + }); + }); + + group('FetchCategoriesPreferencesFailure', () { + test('has correct props', () { + expect(FetchCategoriesPreferencesFailure(error).props, [error]); + }); + }); + }); +} diff --git a/packages/notifications_repository/test/src/notifications_storage_test.dart b/packages/notifications_repository/test/src/notifications_storage_test.dart new file mode 100644 index 00000000..1cb263b4 --- /dev/null +++ b/packages/notifications_repository/test/src/notifications_storage_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:mocktail/mocktail.dart'; +import 'package:notifications_repository/notifications_repository.dart'; +import 'package:storage/storage.dart'; +import 'package:test/test.dart'; + +class MockStorage extends Mock implements Storage {} + +void main() { + group('NotificationsStorage', () { + late Storage storage; + + setUp(() { + storage = MockStorage(); + + when( + () => storage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenAnswer((_) async {}); + }); + + group('setNotificationsEnabled', () { + test('saves the value in Storage', () async { + const enabled = true; + + await NotificationsStorage(storage: storage) + .setNotificationsEnabled(enabled: enabled); + + verify( + () => storage.write( + key: NotificationsStorageKeys.notificationsEnabled, + value: enabled.toString(), + ), + ).called(1); + }); + }); + + group('fetchNotificationsEnabled', () { + test('returns the value from Storage', () async { + when( + () => + storage.read(key: NotificationsStorageKeys.notificationsEnabled), + ).thenAnswer((_) async => 'true'); + + final result = await NotificationsStorage(storage: storage) + .fetchNotificationsEnabled(); + + verify( + () => storage.read( + key: NotificationsStorageKeys.notificationsEnabled, + ), + ).called(1); + + expect(result, isTrue); + }); + + test('returns false when no value exists in Storage', () async { + when( + () => + storage.read(key: NotificationsStorageKeys.notificationsEnabled), + ).thenAnswer((_) async => null); + + final result = await NotificationsStorage(storage: storage) + .fetchNotificationsEnabled(); + + verify( + () => storage.read( + key: NotificationsStorageKeys.notificationsEnabled, + ), + ).called(1); + + expect(result, isFalse); + }); + }); + + group('setCategoriesPreferences', () { + test('saves the value in Storage', () async { + const preferences = { + 'Информация', + 'Объявления', + }; + + await NotificationsStorage(storage: storage).setCategoriesPreferences( + categories: preferences, + ); + + verify( + () => storage.write( + key: NotificationsStorageKeys.categoriesPreferences, + value: json.encode( + preferences, + ), + ), + ).called(1); + }); + }); + + group('fetchCategoriesPreferences', () { + test('returns the value from Storage', () async { + const preferences = { + 'Информация', + 'Объявления', + }; + + when( + () => + storage.read(key: NotificationsStorageKeys.categoriesPreferences), + ).thenAnswer((_) async => json.encode(preferences)); + + final result = await NotificationsStorage(storage: storage) + .fetchCategoriesPreferences(); + + verify( + () => storage.read( + key: NotificationsStorageKeys.categoriesPreferences, + ), + ).called(1); + + expect(result, equals(preferences)); + }); + + test('returns null when no value exists in Storage', () async { + when( + () => + storage.read(key: NotificationsStorageKeys.categoriesPreferences), + ).thenAnswer((_) async => null); + + final result = await NotificationsStorage(storage: storage) + .fetchCategoriesPreferences(); + + verify( + () => storage.read( + key: NotificationsStorageKeys.categoriesPreferences, + ), + ).called(1); + + expect(result, isNull); + }); + }); + }); +} diff --git a/packages/permission_client/.gitignore b/packages/permission_client/.gitignore new file mode 100644 index 00000000..526da158 --- /dev/null +++ b/packages/permission_client/.gitignore @@ -0,0 +1,7 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock \ No newline at end of file diff --git a/packages/permission_client/README.md b/packages/permission_client/README.md new file mode 100644 index 00000000..ad54b7bc --- /dev/null +++ b/packages/permission_client/README.md @@ -0,0 +1,11 @@ +# permission_client + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A client that handles requesting permissions on a device. + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/permission_client/analysis_options.yaml b/packages/permission_client/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/permission_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/permission_client/lib/permission_client.dart b/packages/permission_client/lib/permission_client.dart new file mode 100644 index 00000000..b73da37c --- /dev/null +++ b/packages/permission_client/lib/permission_client.dart @@ -0,0 +1 @@ +export 'src/permission_client.dart'; diff --git a/packages/permission_client/lib/src/permission_client.dart b/packages/permission_client/lib/src/permission_client.dart new file mode 100644 index 00000000..1522d15f --- /dev/null +++ b/packages/permission_client/lib/src/permission_client.dart @@ -0,0 +1,26 @@ +import 'package:permission_handler/permission_handler.dart'; + +export 'package:permission_handler/permission_handler.dart' + show PermissionStatus, PermissionStatusGetters; + +/// {@template permission_client} +/// Клиент для запроса разрешений. +/// {@endtemplate} +class PermissionClient { + /// {@macro permission_client} + const PermissionClient(); + + /// Запрашивает доступ к уведомлениям устройства, + /// если доступ ранее не был предоставлен. + Future requestNotifications() => + Permission.notification.request(); + + /// Возвращает статус доступа к уведомлениям устройства. + Future notificationsStatus() => + Permission.notification.status; + + /// Открывает настройки приложения. + /// + /// Возвращает `true`, если настройки были открыты. + Future openPermissionSettings() => openAppSettings(); +} diff --git a/packages/permission_client/pubspec.yaml b/packages/permission_client/pubspec.yaml new file mode 100644 index 00000000..8c801406 --- /dev/null +++ b/packages/permission_client/pubspec.yaml @@ -0,0 +1,17 @@ +name: permission_client +description: A client that handles requesting permissions on a device. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + +dependencies: + flutter: + sdk: flutter + permission_handler: ^11.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/packages/permission_client/test/src/permission_client_test.dart b/packages/permission_client/test/src/permission_client_test.dart new file mode 100644 index 00000000..13eabfb2 --- /dev/null +++ b/packages/permission_client/test/src/permission_client_test.dart @@ -0,0 +1,97 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:permission_client/permission_client.dart'; +import 'package:permission_handler/permission_handler.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PermissionClient', () { + late PermissionClient permissionClient; + late List calls; + + setUp(() { + permissionClient = PermissionClient(); + calls = []; + + MethodChannel('flutter.baseflow.com/permissions/methods') + .setMockMethodCallHandler((call) async { + calls.add(call); + + if (call.method == 'checkPermissionStatus') { + return PermissionStatus.granted.index; + } else if (call.method == 'requestPermissions') { + return { + for (final key in call.arguments as List) + key: PermissionStatus.granted.index, + }; + } else if (call.method == 'openAppSettings') { + return true; + } + + return null; + }); + }); + + Matcher permissionWasRequested(Permission permission) => contains( + isA() + .having( + (c) => c.method, + 'method', + 'requestPermissions', + ) + .having( + (c) => c.arguments, + 'arguments', + contains(permission.value), + ), + ); + + Matcher permissionWasChecked(Permission permission) => contains( + isA() + .having( + (c) => c.method, + 'method', + 'checkPermissionStatus', + ) + .having( + (c) => c.arguments, + 'arguments', + equals(permission.value), + ), + ); + + group('requestNotifications', () { + test('calls correct method', () async { + await permissionClient.requestNotifications(); + expect(calls, permissionWasRequested(Permission.notification)); + }); + }); + + group('notificationsStatus', () { + test('calls correct method', () async { + await permissionClient.notificationsStatus(); + expect(calls, permissionWasChecked(Permission.notification)); + }); + }); + + group('openPermissionSettings', () { + test('calls correct method', () async { + await permissionClient.openPermissionSettings(); + + expect( + calls, + contains( + isA().having( + (c) => c.method, + 'method', + 'openAppSettings', + ), + ), + ); + }); + }); + }); +} diff --git a/packages/schedule_api_client/.gitignore b/packages/schedule_api_client/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/schedule_api_client/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/schedule_api_client/.metadata b/packages/schedule_api_client/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/schedule_api_client/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/schedule_api_client/LICENSE b/packages/schedule_api_client/LICENSE new file mode 100644 index 00000000..ba75c69f --- /dev/null +++ b/packages/schedule_api_client/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/schedule_api_client/README.md b/packages/schedule_api_client/README.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/schedule_api_client/analysis_options.yaml b/packages/schedule_api_client/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/schedule_api_client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/schedule_api_client/lib/api_client.dart b/packages/schedule_api_client/lib/api_client.dart new file mode 100644 index 00000000..8f15f922 --- /dev/null +++ b/packages/schedule_api_client/lib/api_client.dart @@ -0,0 +1,11 @@ +/// API клиент предоставляет клиентский доступ к удаленному API. +library api_client; + +export 'src/client/schedule_api_client.dart' + show + ScheduleApiClient, + ScheduleApiMalformedResponse, + ScheduleApiRequestFailure; + +export 'src/models/models.dart' + show GroupsResponse, LessonResponse, ScheduleResponse; diff --git a/packages/schedule_api_client/lib/src/client/schedule_api_client.dart b/packages/schedule_api_client/lib/src/client/schedule_api_client.dart new file mode 100644 index 00000000..3a626e57 --- /dev/null +++ b/packages/schedule_api_client/lib/src/client/schedule_api_client.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:schedule_api_client/src/models/groups_response/groups_response.dart'; +import 'package:schedule_api_client/src/models/schedule_response/schedule_response.dart'; + +/// {@template schedule_api_malformed_response} +/// Исключение, генерируемое при возникновении проблемы во время обработки тела +/// ответ. +/// {@endtemplate} +class ScheduleApiMalformedResponse implements Exception { + /// {@macro schedule_api_malformed_response} + const ScheduleApiMalformedResponse({required this.error}); + + /// Связанная ошибка. + final Object error; +} + +/// {@template schedule_api_request_failure} +/// Исключение, генерируемое при ошибке во время http-запроса. +/// {@endtemplate} +class ScheduleApiRequestFailure implements Exception { + /// {@macro schedule_api_request_failure} + const ScheduleApiRequestFailure({ + required this.statusCode, + required this.body, + }); + + /// Связанный http-статус код. + final int statusCode; + + /// Связанное тело ответа. + final Map body; +} + +/// Dart API клиент для Schedule Mirea Ninja API. +class ScheduleApiClient { + /// Создает экземпляр [ScheduleApiClient] для интеграции + /// с удаленным API. + ScheduleApiClient({ + http.Client? httpClient, + }) : this._( + baseUrl: 'https://schedule.mirea.ninja', + httpClient: httpClient, + ); + + /// Создает экземпляр [ScheduleApiClient] для интеграции + /// с локальным API. + /// + /// Используется для тестирования. + ScheduleApiClient.localhost({ + http.Client? httpClient, + }) : this._( + baseUrl: 'http://localhost:8080', + httpClient: httpClient, + ); + + ScheduleApiClient._({ + required String baseUrl, + http.Client? httpClient, + }) : _baseUrl = baseUrl, + _httpClient = httpClient ?? http.Client(); + + final String _baseUrl; + final http.Client _httpClient; + + /// GET /api/schedule/groups + /// Запрос списка групп. + Future getGroups() async { + final uri = Uri.parse('$_baseUrl/api/schedule/groups').replace(); + final response = await _httpClient.get( + uri, + headers: await _getRequestHeaders(), + ); + final body = response.json(); + + if (response.statusCode != HttpStatus.ok) { + throw ScheduleApiRequestFailure( + body: body, + statusCode: response.statusCode, + ); + } + + return GroupsResponse.fromJson(body); + } + + /// GET /api/schedule/{group}/full_schedule + /// Запрос расписания занятий группы. + Future getScheduleByGroup({required String group}) async { + final uri = Uri.parse('$_baseUrl/api/schedule/$group/full_schedule') + .replace(queryParameters: {'remote': 'true'}); + final response = await _httpClient.get( + uri, + headers: await _getRequestHeaders(), + ); + final body = response.json(); + + if (response.statusCode != HttpStatus.ok) { + throw ScheduleApiRequestFailure( + body: body, + statusCode: response.statusCode, + ); + } + + return ScheduleResponse.fromJson(body); + } + + Future> _getRequestHeaders() async { + return { + HttpHeaders.contentTypeHeader: ContentType.json.value, + HttpHeaders.acceptHeader: ContentType.json.value, + }; + } +} + +/// Расширение для [http.Response], которое позволяет получить тело ответа +/// в виде `Map`. Если тело ответа не может быть преобразовано +/// в `Map`, то будет сгенерировано исключение +/// [ScheduleApiMalformedResponse]. +extension on http.Response { + Map json() { + try { + return jsonDecode(body) as Map; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + ScheduleApiMalformedResponse(error: error), + stackTrace, + ); + } + } +} diff --git a/packages/schedule_api_client/lib/src/models/groups_response/groups_response.dart b/packages/schedule_api_client/lib/src/models/groups_response/groups_response.dart new file mode 100644 index 00000000..e6cddaf3 --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/groups_response/groups_response.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'groups_response.g.dart'; + +/// {@template groups_response} +/// Ответ на запрос списка групп +/// {@endtemplate} +@JsonSerializable() +class GroupsResponse extends Equatable { + /// {@macro groups_response} + const GroupsResponse({required this.count, required this.groups}); + + /// Конвертирует `Map` в [GroupsResponse] + factory GroupsResponse.fromJson(Map json) => + _$GroupsResponseFromJson(json); + + /// Максимальное количество групп + final int count; + + /// Список групп + final List groups; + + /// Конвертирует [GroupsResponse] в `Map` + Map toJson() => _$GroupsResponseToJson(this); + + @override + List get props => [count, groups]; +} diff --git a/packages/schedule_api_client/lib/src/models/groups_response/groups_response.g.dart b/packages/schedule_api_client/lib/src/models/groups_response/groups_response.g.dart new file mode 100644 index 00000000..2fe20cd8 --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/groups_response/groups_response.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'groups_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupsResponse _$GroupsResponseFromJson(Map json) => + GroupsResponse( + count: json['count'] as int, + groups: + (json['groups'] as List).map((e) => e as String).toList(), + ); + +Map _$GroupsResponseToJson(GroupsResponse instance) => + { + 'count': instance.count, + 'groups': instance.groups, + }; diff --git a/packages/schedule_api_client/lib/src/models/models.dart b/packages/schedule_api_client/lib/src/models/models.dart new file mode 100644 index 00000000..e176db36 --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/models.dart @@ -0,0 +1,3 @@ +export 'groups_response/groups_response.dart'; +export 'schedule_response/schedule_response.dart'; +export 'schedule_response/lesson.dart'; diff --git a/packages/schedule_api_client/lib/src/models/schedule_response/lesson.dart b/packages/schedule_api_client/lib/src/models/schedule_response/lesson.dart new file mode 100644 index 00000000..a7fef7b5 --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/schedule_response/lesson.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'lesson.g.dart'; + +/// {@template lesson} +/// Занятие в расписании +/// {@endtemplate} +@JsonSerializable() +class LessonResponse extends Equatable { + /// {@macro lesson} + const LessonResponse({ + required this.name, + required this.weeks, + required this.timeStart, + required this.timeEnd, + required this.types, + required this.teachers, + required this.rooms, + }); + + /// Конвертирует `Map` в [LessonResponse] + factory LessonResponse.fromJson(Map json) => + _$LessonFromJson(json); + + /// Название предмета + final String name; + + /// Номера недель, в которые проходит занятие + final List weeks; + + /// Время начала занятия в формате `HH:mm`, если занятие начинается в 9 или + /// раньше, то вместо `HH` используется `H`. Например, 9:00. + final String timeStart; + + /// Время окончания занятия в формате `HH:mm`, если занятие начинается в 9 + /// или раньше, то вместо `HH` используется `H`. Например, 9:00. + final String timeEnd; + + /// Типы занятия. Обычно используется: "пр", "лаб", "лек", "с/р". + final String types; + + /// Преподаватели занятия (ФИО). Обычно в формате "Иванов И. И.". + final List teachers; + + /// Аудитории, в которых проходит занятие. Обычно в формате "А-123 (В-78)". + final List rooms; + + /// Конвертирует [LessonResponse] в `Map` + Map toJson() => _$LessonToJson(this); + + @override + List get props => + [name, weeks, timeStart, timeEnd, types, teachers, rooms]; +} diff --git a/packages/schedule_api_client/lib/src/models/schedule_response/lesson.g.dart b/packages/schedule_api_client/lib/src/models/schedule_response/lesson.g.dart new file mode 100644 index 00000000..e82b3875 --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/schedule_response/lesson.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'lesson.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LessonResponse _$LessonFromJson(Map json) => LessonResponse( + name: json['name'] as String, + weeks: (json['weeks'] as List).map((e) => e as int).toList(), + timeStart: json['timeStart'] as String, + timeEnd: json['timeEnd'] as String, + types: json['types'] as String, + teachers: + (json['teachers'] as List).map((e) => e as String).toList(), + rooms: (json['rooms'] as List).map((e) => e as String).toList(), + ); + +Map _$LessonToJson(LessonResponse instance) => + { + 'name': instance.name, + 'weeks': instance.weeks, + 'timeStart': instance.timeStart, + 'timeEnd': instance.timeEnd, + 'types': instance.types, + 'teachers': instance.teachers, + 'rooms': instance.rooms, + }; diff --git a/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.dart b/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.dart new file mode 100644 index 00000000..e8e7563b --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:schedule_api_client/src/models/schedule_response/lesson.dart'; + +part 'schedule_response.g.dart'; + +/// {@template groups_response} +/// Ответ на запрос расписания занятий группы +/// {@endtemplate} +@JsonSerializable() +class ScheduleResponse extends Equatable { + /// {@macro groups_response} + const ScheduleResponse({ + required this.group, + required this.schedule, + }); + + /// Конвертирует `Map` в [ScheduleResponse] + factory ScheduleResponse.fromJson(Map json) => + _$ScheduleResponseFromJson(json); + + /// Название группы + final String group; + + /// Расписание занятий. Ключ - день недели, где 1 - понедельник, + /// 2 - вторник и т.д. + /// + /// Значение - список списков занятий. Внешний список обычно означает + /// номер пары, то есть всего 7 списков занятий, а внутренний список - + /// список занятий. Если, например, в одно время проходят занятия по четным и + /// нечетным неделям, то этот внутренний список будет содержать 2 элемента. + final Map>> schedule; + + /// Конвертирует [ScheduleResponse] в `Map` + Map toJson() => _$ScheduleResponseToJson(this); + + @override + List get props => [group, schedule]; +} diff --git a/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.g.dart b/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.g.dart new file mode 100644 index 00000000..59ea81ef --- /dev/null +++ b/packages/schedule_api_client/lib/src/models/schedule_response/schedule_response.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'schedule_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduleResponse _$ScheduleResponseFromJson(Map json) => + ScheduleResponse( + group: json['group'] as String, + schedule: (json['schedule'] as Map).map( + (k, e) => MapEntry( + k, + (e as List) + .map((e) => (e as List) + .map((e) => + LessonResponse.fromJson(e as Map)) + .toList()) + .toList()), + ), + ); + +Map _$ScheduleResponseToJson(ScheduleResponse instance) => + { + 'group': instance.group, + 'schedule': instance.schedule, + }; diff --git a/packages/schedule_api_client/pubspec.yaml b/packages/schedule_api_client/pubspec.yaml new file mode 100644 index 00000000..ea18cc96 --- /dev/null +++ b/packages/schedule_api_client/pubspec.yaml @@ -0,0 +1,20 @@ +name: schedule_api_client +description: A new Flutter package project. +version: 0.0.1 +homepage: + +environment: + sdk: '>=3.1.2 <4.0.0' + +dependencies: + collection: ^1.18.0 + equatable: ^2.0.5 + http: ^1.1.0 + json_annotation: ^4.8.1 + +dev_dependencies: + build_runner: ^2.4.6 + json_serializable: ^6.7.1 + mocktail: ^1.0.0 + test: ^1.24.6 + very_good_analysis: ^5.1.0 \ No newline at end of file diff --git a/packages/schedule_api_client/test/src/clients/schedule_api_client_test.dart b/packages/schedule_api_client/test/src/clients/schedule_api_client_test.dart new file mode 100644 index 00000000..18a011f3 --- /dev/null +++ b/packages/schedule_api_client/test/src/clients/schedule_api_client_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:schedule_api_client/api_client.dart'; +import 'package:test/test.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +void main() { + /// Матчер, проверяющий, что [Uri] имеет определенный [authority], [path] и + /// [query]. + Matcher isAUriHaving({String? authority, String? path, String? query}) { + return predicate((uri) { + authority ??= uri.authority; + path ??= uri.path; + query ??= uri.query; + + return uri.authority == authority && + uri.path == path && + uri.query == query; + }); + } + + /// Матчер, проверяющий, что заголовки запроса имеют определенные + /// [HttpHeaders.contentTypeHeader] и [HttpHeaders.acceptHeader] для + /// [ContentType.json]. + Matcher areJsonHeaders({String? authorizationToken}) { + return predicate?>((headers) { + if (headers?[HttpHeaders.contentTypeHeader] != ContentType.json.value || + headers?[HttpHeaders.acceptHeader] != ContentType.json.value) { + return false; + } + if (authorizationToken != null && + headers?[HttpHeaders.authorizationHeader] != + 'Bearer $authorizationToken') { + return false; + } + return true; + }); + } + + group('ScheduleApiClient', () { + late http.Client httpClient; + late ScheduleApiClient apiClient; + + setUpAll(() { + registerFallbackValue(Uri()); + }); + + setUp(() { + httpClient = MockHttpClient(); + apiClient = ScheduleApiClient( + httpClient: httpClient, + ); + }); + + group('localhost constructor', () { + test('can be instantiated (no params).', () { + expect( + ScheduleApiClient.localhost, + returnsNormally, + ); + }); + + test('has correct baseUrl', () async { + const path = '/api/schedule/groups'; + + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response( + jsonEncode(const GroupsResponse(groups: [], count: 0)), + HttpStatus.ok, + ), + ); + final apiClient = ScheduleApiClient.localhost( + httpClient: httpClient, + ); + + await apiClient.getGroups(); + + verify( + () => httpClient.get( + any(that: isAUriHaving(authority: 'localhost:8080', path: path)), + headers: any(named: 'headers', that: areJsonHeaders()), + ), + ).called(1); + }); + }); + + group('getGroups', () { + const groupsResponse = GroupsResponse(count: 0, groups: []); + + test('makes correct http request.', () async { + const path = '/api/schedule/groups'; + + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response(jsonEncode(groupsResponse), HttpStatus.ok), + ); + + await apiClient.getGroups(); + + verify( + () => httpClient.get( + any(that: isAUriHaving(path: path)), + headers: any(named: 'headers', that: areJsonHeaders()), + ), + ).called(1); + }); + + test( + 'throws ScheduleApiMalformedResponse ' + 'when response body is malformed.', () { + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response('', HttpStatus.ok), + ); + + expect( + () => apiClient.getGroups(), + throwsA(isA()), + ); + }); + + test( + 'throws ScheduleApiRequestFailure ' + 'when response has a non-200 status code.', () { + const statusCode = HttpStatus.internalServerError; + final body = {}; + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response(json.encode(body), statusCode), + ); + + expect( + () => apiClient.getGroups(), + throwsA( + isA() + .having((f) => f.statusCode, 'statusCode', statusCode) + .having((f) => f.body, 'body', body), + ), + ); + }); + + test('returns a GroupsResponse on a 200 response.', () { + const expectedResponse = GroupsResponse( + count: 0, + groups: [], + ); + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response( + json.encode(expectedResponse.toJson()), + HttpStatus.ok, + ), + ); + + expect( + apiClient.getGroups(), + completion(equals(expectedResponse)), + ); + }); + }); + }); +} diff --git a/packages/schedule_repository/.gitignore b/packages/schedule_repository/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/schedule_repository/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/schedule_repository/.metadata b/packages/schedule_repository/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/schedule_repository/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/schedule_repository/README.md b/packages/schedule_repository/README.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/schedule_repository/analysis_options.yaml b/packages/schedule_repository/analysis_options.yaml new file mode 100644 index 00000000..a5744c1c --- /dev/null +++ b/packages/schedule_repository/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/schedule_repository/lib/schedule_repository.dart b/packages/schedule_repository/lib/schedule_repository.dart new file mode 100644 index 00000000..c6a0336e --- /dev/null +++ b/packages/schedule_repository/lib/schedule_repository.dart @@ -0,0 +1,4 @@ +export 'package:schedule_api_client/api_client.dart' + show ScheduleResponse, LessonResponse; + +export 'src/schedule_repository.dart'; diff --git a/packages/schedule_repository/lib/src/schedule_repository.dart b/packages/schedule_repository/lib/src/schedule_repository.dart new file mode 100644 index 00000000..53dd502a --- /dev/null +++ b/packages/schedule_repository/lib/src/schedule_repository.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; +import 'package:schedule_api_client/api_client.dart'; +import 'package:storage/storage.dart'; + +part 'schedule_storage.dart'; + +/// {@template news_failure} +/// Базовый класс для ошибок, связанных с расписанием. +/// {@endtemplate} +abstract class ScheduleFailure with EquatableMixin implements Exception { + /// {@macro news_failure} + const ScheduleFailure(this.error); + + /// Связанная ошибка. + final Object error; + + @override + List get props => [error]; +} + +/// {@template get_feed_failure} +/// Вызывается при ошибке получения доступных групп. +/// {@endtemplate} +class GetGroupsFailure extends ScheduleFailure { + /// {@macro get_feed_failure} + const GetGroupsFailure(super.error); +} + +/// {@template get_categories_failure} +/// Вызывается при ошибке получения расписания. +/// {@endtemplate} +class GetScheduleFailure extends ScheduleFailure { + /// {@macro get_categories_failure} + const GetScheduleFailure(super.error); +} + +/// {@template news_repository} +/// Репозиторий для работы с расписанием. +/// {@endtemplate} +class ScheduleRepository { + /// {@macro news_repository} + const ScheduleRepository({ + required ScheduleApiClient apiClient, + required ScheduleStorage storage, + }) : _apiClient = apiClient, + _storage = storage; + + final ScheduleApiClient _apiClient; + final ScheduleStorage _storage; + + /// Запрашивает доступные группы. + Future getGroups() async { + try { + return await _apiClient.getGroups(); + } catch (error, stackTrace) { + Error.throwWithStackTrace(GetGroupsFailure(error), stackTrace); + } + } + + /// Запрашивает расписание для группы. + Future fetchSchedule({required String group}) async { + try { + final schedule = await _apiClient.getScheduleByGroup(group: group); + await _storage.setSchedule(schedule); + return schedule; + } catch (error, stackTrace) { + Error.throwWithStackTrace(GetScheduleFailure(error), stackTrace); + } + } +} diff --git a/packages/schedule_repository/lib/src/schedule_storage.dart b/packages/schedule_repository/lib/src/schedule_storage.dart new file mode 100644 index 00000000..ee84a73b --- /dev/null +++ b/packages/schedule_repository/lib/src/schedule_storage.dart @@ -0,0 +1,31 @@ +part of 'schedule_repository.dart'; + +/// Ключи для хранилища [ScheduleStorage]. +abstract class ScheduleStorageKeys { + /// Ключ для хранения расписания. + static const schedules = '__schedule_key__'; + + /// Возвращает ключ, связанный с группой [group] для хранения расписания + /// указанной группы. + static getScheduleKey(String group) => "$group$schedules"; +} + +/// {@template article_storage} +/// Хранилище для [ScheduleRepository]. +/// {@endtemplate} +class ScheduleStorage { + /// {@macro article_storage} + const ScheduleStorage({ + required Storage storage, + }) : _storage = storage; + + final Storage _storage; + + /// Записывает расписание в хранилище. + Future setSchedule(ScheduleResponse schedule) async { + await _storage.write( + key: ScheduleStorageKeys.getScheduleKey(schedule.group), + value: jsonEncode(schedule.toJson()), + ); + } +} diff --git a/packages/schedule_repository/pubspec.yaml b/packages/schedule_repository/pubspec.yaml new file mode 100644 index 00000000..a8f62167 --- /dev/null +++ b/packages/schedule_repository/pubspec.yaml @@ -0,0 +1,17 @@ +name: schedule_repository +description: A new Flutter package project. +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + equatable: ^2.0.5 + schedule_api_client: + path: ../schedule_api_client + storage: + path: ../storage/storage + +dev_dependencies: + very_good_analysis: ^4.0.0 diff --git a/packages/schedule_repository/test/schedule_repository_test.dart b/packages/schedule_repository/test/schedule_repository_test.dart new file mode 100644 index 00000000..4880d30c --- /dev/null +++ b/packages/schedule_repository/test/schedule_repository_test.dart @@ -0,0 +1,2 @@ + +void main() {} diff --git a/packages/storage/persistent_storage/.gitignore b/packages/storage/persistent_storage/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/storage/persistent_storage/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/storage/persistent_storage/.metadata b/packages/storage/persistent_storage/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/storage/persistent_storage/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/storage/persistent_storage/README.md b/packages/storage/persistent_storage/README.md new file mode 100644 index 00000000..46f9f35d --- /dev/null +++ b/packages/storage/persistent_storage/README.md @@ -0,0 +1,3 @@ +# persistent_storage + +Пакет для работы с постоянным хранилищем. \ No newline at end of file diff --git a/packages/storage/persistent_storage/analysis_options.yaml b/packages/storage/persistent_storage/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/storage/persistent_storage/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/storage/persistent_storage/lib/persistent_storage.dart b/packages/storage/persistent_storage/lib/persistent_storage.dart new file mode 100644 index 00000000..f863a60d --- /dev/null +++ b/packages/storage/persistent_storage/lib/persistent_storage.dart @@ -0,0 +1 @@ +export 'src/persistent_storage.dart'; diff --git a/packages/storage/persistent_storage/lib/src/persistent_storage.dart b/packages/storage/persistent_storage/lib/src/persistent_storage.dart new file mode 100644 index 00000000..ba6948bd --- /dev/null +++ b/packages/storage/persistent_storage/lib/src/persistent_storage.dart @@ -0,0 +1,50 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:storage/storage.dart'; + +/// {@template persistent_storage} +/// Хранилище, релазизующее [Storage] с помощью `SharedPreferences`. +/// {@endtemplate} +class PersistentStorage implements Storage { + /// {@macro persistent_storage} + const PersistentStorage({ + required SharedPreferences sharedPreferences, + }) : _sharedPreferences = sharedPreferences; + + final SharedPreferences _sharedPreferences; + + @override + Future read({required String key}) async { + try { + return _sharedPreferences.getString(key); + } catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future write({required String key, required String value}) async { + try { + await _sharedPreferences.setString(key, value); + } catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future delete({required String key}) async { + try { + await _sharedPreferences.remove(key); + } catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future clear() async { + try { + await _sharedPreferences.clear(); + } catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } +} diff --git a/packages/storage/persistent_storage/pubspec.yaml b/packages/storage/persistent_storage/pubspec.yaml new file mode 100644 index 00000000..e0362d2e --- /dev/null +++ b/packages/storage/persistent_storage/pubspec.yaml @@ -0,0 +1,20 @@ +name: persistent_storage +description: Storage that saves data in the device's persistent memory. +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + shared_preferences: ^2.2.1 + storage: + path: ../storage + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 \ No newline at end of file diff --git a/packages/storage/persistent_storage/test/persistent_storage_test.dart b/packages/storage/persistent_storage/test/persistent_storage_test.dart new file mode 100644 index 00000000..d30207dd --- /dev/null +++ b/packages/storage/persistent_storage/test/persistent_storage_test.dart @@ -0,0 +1,3 @@ + + +void main() {} diff --git a/packages/storage/secure_storage/.gitignore b/packages/storage/secure_storage/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/storage/secure_storage/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/storage/secure_storage/.metadata b/packages/storage/secure_storage/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/storage/secure_storage/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/storage/secure_storage/README.md b/packages/storage/secure_storage/README.md new file mode 100644 index 00000000..79c02d84 --- /dev/null +++ b/packages/storage/secure_storage/README.md @@ -0,0 +1,5 @@ +# Secure Storage + +[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) + +Пакет, реализующий безопасное хранение данных на стороне клиента в зашифрованном виде. diff --git a/packages/storage/secure_storage/analysis_options.yaml b/packages/storage/secure_storage/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/storage/secure_storage/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/storage/secure_storage/lib/secure_storage.dart b/packages/storage/secure_storage/lib/secure_storage.dart new file mode 100644 index 00000000..b8ba0549 --- /dev/null +++ b/packages/storage/secure_storage/lib/secure_storage.dart @@ -0,0 +1 @@ +export 'src/secure_storage.dart'; diff --git a/packages/storage/secure_storage/lib/src/secure_storage.dart b/packages/storage/secure_storage/lib/src/secure_storage.dart new file mode 100644 index 00000000..c22ee6be --- /dev/null +++ b/packages/storage/secure_storage/lib/src/secure_storage.dart @@ -0,0 +1,49 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:storage/storage.dart'; + +/// {@template secure_storage} +/// Хранилище, использующее [FlutterSecureStorage] для хранения данных. +/// {@endtemplate} +class SecureStorage implements Storage { + /// {@macro secure_storage} + const SecureStorage([FlutterSecureStorage? secureStorage]) + : _secureStorage = secureStorage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _secureStorage; + + @override + Future read({required String key}) async { + try { + return await _secureStorage.read(key: key); + } on Exception catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future write({required String key, required String value}) async { + try { + await _secureStorage.write(key: key, value: value); + } on Exception catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future delete({required String key}) async { + try { + await _secureStorage.delete(key: key); + } on Exception catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } + + @override + Future clear() async { + try { + await _secureStorage.deleteAll(); + } on Exception catch (error, stackTrace) { + Error.throwWithStackTrace(StorageException(error), stackTrace); + } + } +} diff --git a/packages/storage/secure_storage/pubspec.yaml b/packages/storage/secure_storage/pubspec.yaml new file mode 100644 index 00000000..7a89233a --- /dev/null +++ b/packages/storage/secure_storage/pubspec.yaml @@ -0,0 +1,20 @@ +name: persistent_storage +description: Storage that saves data in the device's persistent memory. +publish_to: none + +environment: + sdk: '>=3.1.2 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + flutter_secure_storage: ^9.0.0 + storage: + path: ../storage + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 \ No newline at end of file diff --git a/packages/storage/secure_storage/test/secure_storage_test.dart b/packages/storage/secure_storage/test/secure_storage_test.dart new file mode 100644 index 00000000..4880d30c --- /dev/null +++ b/packages/storage/secure_storage/test/secure_storage_test.dart @@ -0,0 +1,2 @@ + +void main() {} diff --git a/packages/storage/storage/.gitignore b/packages/storage/storage/.gitignore new file mode 100644 index 00000000..96486fd9 --- /dev/null +++ b/packages/storage/storage/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/storage/storage/.metadata b/packages/storage/storage/.metadata new file mode 100644 index 00000000..e5c802c4 --- /dev/null +++ b/packages/storage/storage/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: package diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md new file mode 100644 index 00000000..86664a6e --- /dev/null +++ b/packages/storage/storage/README.md @@ -0,0 +1,5 @@ +# Storage + +[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) + +Пакет Key/Value клиентского хранилища. diff --git a/packages/storage/storage/analysis_options.yaml b/packages/storage/storage/analysis_options.yaml new file mode 100644 index 00000000..670d9396 --- /dev/null +++ b/packages/storage/storage/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file diff --git a/packages/storage/storage/lib/storage.dart b/packages/storage/storage/lib/storage.dart new file mode 100644 index 00000000..4cb20700 --- /dev/null +++ b/packages/storage/storage/lib/storage.dart @@ -0,0 +1,30 @@ +/// {@template storage_exception} +/// Исключение, если операция в хранилище не удалась. +/// {@endtemplate} +class StorageException implements Exception { + /// {@macro storage_exception} + const StorageException(this.error); + + /// Связанная ошибка. + final Object error; +} + +/// Интерфейс клиентского хранилища. +abstract class Storage { + /// Возвращает значение по указанному ключу [key]. + /// Вернёт `null`, если по ключу [key] нет значения. + /// * Выбросит [StorageException], если чтение не удалось. + Future read({required String key}); + + /// Записывает указанную пару [key], [value] асинхронно. + /// * Выбросит [StorageException], если запись не удалась. + Future write({required String key, required String value}); + + /// Удаляет значение по указанному ключу [key] асинхронно. + /// * Выбросит [StorageException], если удаление не удалось. + Future delete({required String key}); + + /// Удаляет все значения из хранилища асинхронно. + /// * Выбросит [StorageException], если очистка не удалась. + Future clear(); +} diff --git a/packages/storage/storage/pubspec.yaml b/packages/storage/storage/pubspec.yaml new file mode 100644 index 00000000..55406a1a --- /dev/null +++ b/packages/storage/storage/pubspec.yaml @@ -0,0 +1,11 @@ +name: storage +description: A Key/Value Storage Client for Dart. +version: 0.0.1 +homepage: + +environment: + sdk: '>=3.1.2 <4.0.0' + +dev_dependencies: + test: ^1.24.6 + very_good_analysis: ^5.1.0 \ No newline at end of file diff --git a/packages/storage/storage/test/storage_test.dart b/packages/storage/storage/test/storage_test.dart new file mode 100644 index 00000000..d30207dd --- /dev/null +++ b/packages/storage/storage/test/storage_test.dart @@ -0,0 +1,3 @@ + + +void main() {} diff --git a/pubspec.yaml b/pubspec.yaml index d6734e83..5371a2b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,7 @@ publish_to: 'none' # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.3.6+30 +version: 1.3.7+31 environment: sdk: ">=3.1.1" @@ -134,8 +134,6 @@ dependencies: syncfusion_flutter_charts: ^22.2.12 syncfusion_flutter_gauges: ^22.2.12 - auto_route: ^5.0.4 - url_launcher: ^6.0.17 flutter_markdown: ^0.6.9 @@ -166,9 +164,10 @@ dependencies: freezed_annotation: ^2.1.0 - firebase_core: ^2.13.0 + firebase_core: ^2.16.0 firebase_core_web: ^2.5.0 firebase_analytics: ^10.4.1 + firebase_messaging: ^14.6.8 oauth2_client: ^3.2.2 @@ -195,10 +194,22 @@ dependencies: sentry_logging: ^7.9.0 logging: ^1.1.1 + sliver_tools: ^0.2.12 + # 1000+ beautiful icons to use in you dream project, with all the customization Flutter # provides # See https://pub.dev/packages/unicons unicons: any + go_router: ^10.1.2 + + notifications_repository: + path: packages/notifications_repository + permission_client: + path: packages/permission_client + firebase_notifications_client: + path: packages/notifications_client/firebase_notifications_client + persistent_storage: + path: packages/storage/persistent_storage dev_dependencies: @@ -211,7 +222,6 @@ dev_dependencies: flutter_test: sdk: flutter flutter_launcher_icons: ^0.13.1 - auto_route_generator: ^5.0.3 build_runner: # https://pub.dev/packages/freezed diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ea1e54e5..45138ebe 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SentryFlutterPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SentryFlutterPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7f637cd8..736577ef 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus firebase_core flutter_secure_storage_windows + permission_handler_windows sentry_flutter url_launcher_windows window_to_front