diff --git a/lib/main.dart b/lib/main.dart index 46499b4b..b58323e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ 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'; @@ -59,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(); } @@ -181,6 +182,9 @@ class App extends StatelessWidget { BlocProvider( create: (_) => getIt(), ), + BlocProvider( + create: (_) => getIt(), + ), ], child: Consumer( builder: (BuildContext context, AppNotifier value, Widget? child) { 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..181d50d3 --- /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 transliteration = { + 'А': 'A', + 'Б': 'B', + 'В': 'V', + 'Г': 'G', + 'Д': 'D', + 'Е': 'E', + 'Ё': 'E', + 'Ж': 'Zh', + 'З': 'Z', + 'И': 'I', + 'Й': 'I', + 'К': 'K', + 'Л': 'L', + 'М': 'M', + 'Н': 'N', + 'О': 'O', + 'П': 'P', + 'Р': 'R', + 'С': 'S', + 'Т': 'T', + 'У': 'U', + 'Ф': 'F', + 'Х': 'Kh', + 'Ц': 'Ts', + 'Ч': 'Ch', + 'Ш': 'Sh', + 'Щ': 'Shch', + 'Ъ': '', + 'Ы': 'Y', + 'Ь': '', + 'Э': 'E', + 'Ю': 'Yu', + 'Я': 'Ya', + }; + + return groupName + .split('-') + .map((word) => + word.split('').map((char) => transliteration[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 8574c5dc..f2031c04 100644 --- a/lib/presentation/core/routes/routes.dart +++ b/lib/presentation/core/routes/routes.dart @@ -6,6 +6,7 @@ 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'; @@ -134,6 +135,13 @@ GoRouter createRouter() => GoRouter( GoRoute( path: 'settings', builder: (context, state) => const ProfileSettingsPage(), + routes: [ + GoRoute( + path: 'notifications', + builder: (context, state) => + const NotificationsSettingsPage(), + ), + ], ), GoRoute( path: 'nfc-pass', 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..e9e45d45 --- /dev/null +++ b/lib/presentation/pages/profile/notifications_settings_page.dart @@ -0,0 +1,145 @@ +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: [ + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: AppTextStyle.buttonL), + const SizedBox(height: 4), + Text(description, style: AppTextStyle.body), + ]), + 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 6bc606ba..9ace7441 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -4,6 +4,7 @@ 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'; @@ -56,7 +57,23 @@ class ProfilePage extends PageWithThemeConsumer { ), ), logInError: (st) => const _InitialProfileStatePage(), - logInSuccess: (st) => _UserLoggedInView(user: st.user), + logInSuccess: (st) => BlocBuilder< + NotificationPreferencesBloc, + NotificationPreferencesState>( + builder: (BuildContext context, + NotificationPreferencesState state) { + if (state.categories.isEmpty) { + BlocProvider.of( + context) + .add( + InitialCategoriesPreferencesRequested( + group: UserBloc.getActiveStudent(st.user) + .academicGroup), + ); + } + return _UserLoggedInView(user: st.user); + }, + ), ); }, ), 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 1b479bd2..b9967e16 100644 --- a/lib/presentation/pages/profile/profile_settings_page.dart +++ b/lib/presentation/pages/profile/profile_settings_page.dart @@ -4,7 +4,6 @@ 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:rtu_mirea_app/service_locator.dart'; class ProfileSettingsPage extends StatelessWidget { const ProfileSettingsPage({Key? key}) : super(key: key); @@ -73,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/service_locator.dart b/lib/service_locator.dart index 5bac7de8..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,6 +73,7 @@ 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'; @@ -140,6 +146,8 @@ Future setup() async { )); getIt .registerFactory(() => NfcFeedbackBloc(sendNfcNotExistFeedback: getIt())); + getIt.registerFactory( + () => NotificationPreferencesBloc(notificationsRepository: getIt())); // Usecases getIt.registerLazySingleton(() => GetStories(getIt())); @@ -214,6 +222,13 @@ Future setup() async { localDataSource: getIt(), )); + getIt.registerLazySingleton( + () => NotificationsRepository( + permissionClient: getIt(), + storage: getIt(), + notificationsClient: getIt(), + )); + getIt.registerLazySingleton(() => UserLocalDataImpl( sharedPreferences: getIt(), secureStorage: getIt(), @@ -260,4 +275,12 @@ Future setup() async { 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/lib/src/client/news_api_client.dart b/packages/news_api_client/lib/src/client/news_api_client.dart index 110b5443..3fdbe040 100644 --- a/packages/news_api_client/lib/src/client/news_api_client.dart +++ b/packages/news_api_client/lib/src/client/news_api_client.dart @@ -146,7 +146,7 @@ class NewsApiClient { // Ключ 'CNT' - количество использований тега. rawTags .where( - (rawTag) => int.parse(rawTag['CNT'] as String) > tagUsageCount) + (rawTag) => int.parse(rawTag['CNT'] as String) > tagUsageCount,) .map((rawTag) => rawTag['NAME'] as String), ); } catch (error, stackTrace) { 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 index 29a5b712..72c199e7 100644 --- 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 @@ -39,7 +39,7 @@ class NewsResponse extends Equatable { /// Дата публикации новости. @JsonKey( - name: 'DATE_ACTIVE_FROM', fromJson: _dateFromJson, toJson: _dateToJson) + name: 'DATE_ACTIVE_FROM', fromJson: _dateFromJson, toJson: _dateToJson,) final DateTime date; /// Ссылки на изображения новости. 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_repository/test/schedule_repository_test.dart b/packages/schedule_repository/test/schedule_repository_test.dart index c3f7c380..4880d30c 100644 --- a/packages/schedule_repository/test/schedule_repository_test.dart +++ b/packages/schedule_repository/test/schedule_repository_test.dart @@ -1,3 +1,2 @@ -import 'package:schedule_repository/schedule_repository.dart'; void main() {} diff --git a/pubspec.yaml b/pubspec.yaml index ada6edd9..52675195 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -164,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 @@ -199,6 +200,15 @@ dependencies: 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: # The "flutter_lints" package below contains a set of recommended lints to 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