diff --git a/README.md b/README.md index 9f31735a..61e1341f 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,28 @@ flutter packages pub run build_runner watch --delete-conflicting-outputs flutter packages pub run build_runner build --delete-conflicting-outputs ``` -## Конфигурация Firebase Analytics и Crashlytics +## Конфигурация Firebase Analytics 1. Зарегистрируйте приложение в [Firebase](https://console.firebase.google.com/). 1. Выполните шаги для генерации `firebase_options.dart` файла с помощью [FlutterFire CLI](https://firebase.flutter.dev/docs/cli). 2. Firebase Analytics для Android не поддерживает Dart-only конфигурацию. Как только ваше приложение для Android будет зарегистрировано в Firebase, загрузите файл конфигурации с консоли Firebase (файл называется `google-services.json`). Добавьте этот файл в каталог `android/app`. 3. Проект готов для использования с Firebase Analytics и Crashlytics. +## Переменные окружения +Приложение использует переменные среды времени компиляции для хранения конфиденциальных данных, таких как ключи API и токены. + +Эти переменные должны передаваться при запуске или сборке приложения с помощью аргумента `--dart-define` или установленной переменной окружения. Если вам нужно передать несколько пар ключ-значение, просто определите --dart-define несколько раз. + +### Переменные приложения: +- `SENTRY_DSN` - DSN для отправки отчетов об ошибках в Sentry. +- `LK_CLIENT_ID` - ID клиента для авторизации в Личном кабинете с помощью OAuth2. +- `LK_CLIENT_SECRET` - Секретный ключ клиента для авторизации в Личном кабинете с помощью OAuth2. + +**Пример:** +```bash +flutter run --dart-define=SENTRY_DSN=YOUR_DSN --dart-define=LK_CLIENT_ID=YOUR_CLIENT_ID --dart-define=LK_CLIENT_SECRET=YOUR_CLIENT_SECRET +``` + + ## При ошибках **Исключения платформы** 1. flutter clean diff --git a/android/build.gradle b/android/build.gradle index 3ae76140..86c61dc8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,7 +9,7 @@ buildscript { classpath 'com.android.tools.build:gradle:7.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" - classpath 'com.google.gms:google-services:4.3.8' + classpath 'com.google.gms:google-services:4.3.15' } } diff --git a/assets/icons/social-sharing.svg b/assets/icons/social-sharing.svg new file mode 100644 index 00000000..165d04d9 --- /dev/null +++ b/assets/icons/social-sharing.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/data/datasources/user_remote.dart b/lib/data/datasources/user_remote.dart index 0c984f19..386fa5ef 100644 --- a/lib/data/datasources/user_remote.dart +++ b/lib/data/datasources/user_remote.dart @@ -9,6 +9,7 @@ import 'package:rtu_mirea_app/data/models/employee_model.dart'; import 'package:rtu_mirea_app/data/models/nfc_pass_model.dart'; import 'package:rtu_mirea_app/data/models/score_model.dart'; import 'package:rtu_mirea_app/data/models/user_model.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; abstract class UserRemoteData { Future auth(); @@ -56,18 +57,25 @@ class UserRemoteDataImpl implements UserRemoteData { final response = await lksOauth2.oauth2Helper.get( '$_apiUrl/?action=getData&url=https://lk.mirea.ru/profile/', ); - var jsonResponse = json.decode(response.body); log('Status code: ${response.statusCode}, Response: ${response.body}', name: 'getProfileData'); - if (jsonResponse.containsKey('errors')) { - throw ServerException(jsonResponse['errors'][0]); - } - if (response.statusCode == 200) { - return UserModel.fromRawJson(response.body); - } else { - throw ServerException('Response status code is ${response.statusCode}'); + try { + var jsonResponse = json.decode(response.body); + + if (jsonResponse.containsKey('errors')) { + throw ServerException(jsonResponse['errors'][0]); + } + if (response.statusCode == 200) { + return UserModel.fromJson(jsonResponse); + } else { + throw ServerException('Response status code is ${response.statusCode}'); + } + } catch (e) { + Sentry.captureException(e, stackTrace: StackTrace.current); + + throw ServerException(e.toString()); } } diff --git a/lib/data/models/edu_program_model.dart b/lib/data/models/edu_program_model.dart new file mode 100644 index 00000000..6a93d4e2 --- /dev/null +++ b/lib/data/models/edu_program_model.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:rtu_mirea_app/domain/entities/edu_program.dart'; + +class EduProgramModel extends EduProgram { + const EduProgramModel({ + required eduProgram, + required eduProgramCode, + required department, + required prodDepartment, + required type, + }) : super( + eduProgram: eduProgram, + eduProgramCode: eduProgramCode, + department: department, + prodDepartment: prodDepartment, + type: type, + ); + + factory EduProgramModel.fromRawJson(String str) => + EduProgramModel.fromJson(json.decode(str)); + + factory EduProgramModel.fromJson(Map json) { + return EduProgramModel( + eduProgramCode: json["PROPERTIES"]["OKSO_CODE"]["VALUE"], + eduProgram: json["NAME"], + department: json["PROPERTIES"]["DEPARTMENT"]["VALUE_TEXT"], + prodDepartment: json["PROPERTIES"]["PROD_DEPARTMENT"]["VALUE_TEXT"], + type: json["TYPE"], + ); + } +} diff --git a/lib/data/models/student_model.dart b/lib/data/models/student_model.dart new file mode 100644 index 00000000..4fa2d2af --- /dev/null +++ b/lib/data/models/student_model.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:rtu_mirea_app/domain/entities/student.dart'; + +import 'edu_program_model.dart'; + +class StudentModel extends Student { + const StudentModel({ + required id, + required isActive, + required course, + required personalNumber, + required educationStartDate, + required educationEndDate, + required academicGroup, + required code, + required eduProgram, + required status, + }) : super( + id: id, + isActive: isActive, + eduProgram: eduProgram, + course: course, + personalNumber: personalNumber, + educationStartDate: educationStartDate, + educationEndDate: educationEndDate, + academicGroup: academicGroup, + code: code, + status: status, + ); + + static List fromRawJson(String str) => + StudentModel.fromJson(json.decode(str)); + + static List fromJson(Map json) { + final studentsRaw = json["STUDENTS"].values.where((element) => + !element["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"].contains("Д") && + !element["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"].contains("Ж")); + + return List.from(studentsRaw.map( + (e) => StudentModel( + id: e["ID"], + isActive: e["ACTIVE"] == "Y", + course: int.parse(e["PROPERTIES"]["COURSE"]["VALUE"]), + personalNumber: e["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"], + educationEndDate: e["PROPERTIES"]["END_DATE"]["VALUE"], + academicGroup: e["PROPERTIES"]["ACADEMIC_GROUP"]["VALUE_TEXT"], + educationStartDate: e["PROPERTIES"]["START_DATE"]["VALUE"], + code: e["CODE"], + eduProgram: EduProgramModel.fromJson( + json["EDU_PROGRAM"][e["PROPERTIES"]["EDU_PROGRAM"]["VALUE"]]), + status: e["PROPERTIES"]["STATUS"]["VALUE_TEXT"], + ), + )); + } +} diff --git a/lib/data/models/user_model.dart b/lib/data/models/user_model.dart index 9a83920f..a7bcad75 100644 --- a/lib/data/models/user_model.dart +++ b/lib/data/models/user_model.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:rtu_mirea_app/data/models/student_model.dart'; import 'package:rtu_mirea_app/domain/entities/user.dart'; class UserModel extends User { @@ -10,24 +11,11 @@ class UserModel extends User { required name, required lastName, required secondName, - required isActive, required birthday, - required eduProgram, - required eduProgramCode, required photoUrl, - required authShortlink, required registerDate, required lastLoginDate, - required course, - required personalNumber, - required educationStartDate, - required educationEndDate, - required academicGroup, - required department, - required prodDepartment, - required type, - required code, - required studentId, + required students, }) : super( id: id, login: login, @@ -35,38 +23,17 @@ class UserModel extends User { name: name, lastName: lastName, secondName: secondName, - isActive: isActive, birthday: birthday, - eduProgram: eduProgram, - eduProgramCode: eduProgramCode, photoUrl: photoUrl, - authShortlink: authShortlink, registerDate: registerDate, lastLoginDate: lastLoginDate, - course: course, - personalNumber: personalNumber, - educationStartDate: educationStartDate, - educationEndDate: educationEndDate, - academicGroup: academicGroup, - department: department, - prodDepartment: prodDepartment, - type: type, - code: code, - studentId: studentId, + students: students, ); factory UserModel.fromRawJson(String str) => UserModel.fromJson(json.decode(str)); factory UserModel.fromJson(Map json) { - final student = json["STUDENTS"].values.firstWhere((element) => - !element["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"].contains("Д") && - !element["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"].contains("Ж")); - - final eduProgram = json["EDU_PROGRAM"] - .values - .firstWhere((element) => element["ACTIVE"] == "Y"); - return UserModel( id: json["ID"], login: json["arUser"]["LOGIN"], @@ -74,24 +41,11 @@ class UserModel extends User { name: json["arUser"]["NAME"], lastName: json["arUser"]["LAST_NAME"], secondName: json["arUser"]["SECOND_NAME"], - isActive: student["ACTIVE"] == "Y", photoUrl: json["arUser"]["PHOTO"], - authShortlink: json["arUser"]["UF_AUTH_SHORTLINK"], lastLoginDate: json["arUser"]["LAST_LOGIN"], registerDate: json["arUser"]["DATE_REGISTER"], - course: int.parse(student["PROPERTIES"]["COURSE"]["VALUE"]), - personalNumber: student["PROPERTIES"]["PERSONAL_NUMBER"]["VALUE"], birthday: json["arUser"]["PERSONAL_BIRTHDAY"], - educationStartDate: student["PROPERTIES"]["START_DATE"]["VALUE"], - educationEndDate: student["PROPERTIES"]["END_DATE"]["VALUE"], - academicGroup: student["PROPERTIES"]["ACADEMIC_GROUP"]["VALUE_TEXT"], - eduProgramCode: eduProgram["PROPERTIES"]["OKSO_CODE"]["VALUE"], - eduProgram: eduProgram["NAME"], - department: eduProgram["PROPERTIES"]["DEPARTMENT"]["VALUE_TEXT"], - prodDepartment: eduProgram["PROPERTIES"]["PROD_DEPARTMENT"]["VALUE_TEXT"], - type: eduProgram["TYPE"], - code: student["CODE"], - studentId: student["ID"], + students: StudentModel.fromJson(json), ); } } diff --git a/lib/domain/entities/edu_program.dart b/lib/domain/entities/edu_program.dart new file mode 100644 index 00000000..89f9abf1 --- /dev/null +++ b/lib/domain/entities/edu_program.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; + +class EduProgram extends Equatable { + final String eduProgram; + final String eduProgramCode; + final String department; + final String prodDepartment; + final String? type; + + const EduProgram({ + required this.eduProgram, + required this.eduProgramCode, + required this.department, + required this.prodDepartment, + required this.type, + }); + + @override + List get props => [ + eduProgram, + eduProgramCode, + department, + prodDepartment, + type, + ]; +} diff --git a/lib/domain/entities/student.dart b/lib/domain/entities/student.dart new file mode 100644 index 00000000..8c0f2233 --- /dev/null +++ b/lib/domain/entities/student.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +import 'edu_program.dart'; + +class Student extends Equatable { + final String id; + final bool isActive; + final int course; + final String personalNumber; + final String educationStartDate; + final String educationEndDate; + final String academicGroup; + final String code; + final EduProgram eduProgram; + final String status; // must be 'активный' + + const Student({ + required this.id, + required this.isActive, + required this.course, + required this.personalNumber, + required this.educationStartDate, + required this.educationEndDate, + required this.academicGroup, + required this.code, + required this.eduProgram, + required this.status, + }); + + @override + List get props => [ + id, + isActive, + course, + personalNumber, + educationStartDate, + educationEndDate, + academicGroup, + code, + eduProgram, + status, + ]; +} diff --git a/lib/domain/entities/user.dart b/lib/domain/entities/user.dart index 4462051f..d7dae0b5 100644 --- a/lib/domain/entities/user.dart +++ b/lib/domain/entities/user.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'student.dart'; + class User extends Equatable { final int id; final String login; @@ -7,24 +9,12 @@ class User extends Equatable { final String name; final String lastName; final String secondName; - final bool isActive; final String birthday; - final String eduProgram; - final String eduProgramCode; final String photoUrl; - final String? authShortlink; final String registerDate; final String lastLoginDate; - final int course; - final String personalNumber; - final String educationStartDate; - final String educationEndDate; - final String academicGroup; - final String department; - final String prodDepartment; - final String type; - final String code; - final String studentId; + + final List students; const User({ required this.id, @@ -33,24 +23,11 @@ class User extends Equatable { required this.name, required this.lastName, required this.secondName, - required this.isActive, required this.birthday, - required this.eduProgram, - required this.eduProgramCode, required this.photoUrl, - required this.authShortlink, required this.registerDate, required this.lastLoginDate, - required this.course, - required this.personalNumber, - required this.educationStartDate, - required this.educationEndDate, - required this.academicGroup, - required this.department, - required this.prodDepartment, - required this.type, - required this.code, - required this.studentId, + required this.students, }); @override @@ -61,23 +38,10 @@ class User extends Equatable { name, lastName, secondName, - isActive, birthday, - eduProgram, - eduProgramCode, photoUrl, - authShortlink, registerDate, lastLoginDate, - course, - personalNumber, - educationStartDate, - educationEndDate, - academicGroup, - department, - prodDepartment, - type, - code, - studentId, + students, ]; } diff --git a/lib/main.dart b/lib/main.dart index ea1d7c20..fc98fcaa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ 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'; import 'package:flutter/material.dart'; @@ -31,14 +32,29 @@ 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; +import 'package:sentry_dio/sentry_dio.dart'; import 'package:url_strategy/url_strategy.dart'; import 'presentation/app_notifier.dart'; import 'service_locator.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'firebase_options.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:provider/provider.dart'; +import 'package:sentry_logging/sentry_logging.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +class GlobalBlocObserver extends BlocObserver { + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + Sentry.captureException(error, stackTrace: stackTrace); + + if (kDebugMode) { + print(stackTrace); + } + + super.onError(bloc, error, stackTrace); + } +} Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -51,22 +67,9 @@ Future main() async { options: DefaultFirebaseOptions.currentPlatform, ); - // Crashlytics instance must be initialized only on non-web platforms. On web - // platforms, it is throw an exception. - if (!kIsWeb) { - if (kDebugMode) { - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); - } else { - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); - } - } - await FirebaseAnalytics.instance.logAppOpen(); if (kDebugMode) { - // Force disable Crashlytics collection while doing every day development - await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); - // Clear Secure Storage var secureStorage = getIt(); await secureStorage.deleteAll(); @@ -90,10 +93,43 @@ Future main() async { Intl.systemLocale = await findSystemLocale(); } - runApp(ChangeNotifierProvider( - create: (context) => getIt(), - child: const App(), - )); + Bloc.observer = GlobalBlocObserver(); + + await SentryFlutter.init( + (options) { + options.dsn = + const String.fromEnvironment('SENTRY_DSN', defaultValue: ''); + + // Set tracesSampleRate to 0.2 to capture 20% of transactions for + // performance monitoring. + options.tracesSampleRate = 0.2; + + options.enableAutoPerformanceTracking = true; + + options.attachScreenshot = true; + + options.addIntegration(LoggingIntegration()); + }, + appRunner: () => runApp( + /// When a user experiences an error, an exception or a crash, + /// Sentry provides the ability to take a screenshot and include + /// it as an attachment. + SentryScreenshotWidget( + child: DefaultAssetBundle( + /// The AssetBundle instrumentation provides insight into how long + /// app takes to load its assets, such as files + bundle: SentryAssetBundle(), + child: ChangeNotifierProvider( + create: (context) => getIt(), + child: const App(), + ), + ), + ), + ), + ).then((value) { + final Dio dio = getIt(); + dio.addSentry(); + }); } class App extends StatelessWidget { @@ -169,6 +205,7 @@ class App extends StatelessWidget { analytics: FirebaseAnalytics.instance, ), AutoRouteObserver(), + SentryNavigatorObserver(), ], ), routeInformationProvider: appRouter.routeInfoProvider(), diff --git a/lib/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart b/lib/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart index 8e90f071..ee1909f4 100644 --- a/lib/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart +++ b/lib/presentation/bloc/nfc_pass_bloc/nfc_pass_bloc.dart @@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_nfc_kit/flutter_nfc_kit.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:get/get.dart'; import 'package:rtu_mirea_app/common/errors/failures.dart'; import 'package:rtu_mirea_app/domain/entities/nfc_pass.dart'; import 'package:rtu_mirea_app/domain/usecases/connect_nfc_pass.dart'; @@ -131,8 +132,12 @@ class NfcPassBloc extends Bloc { userDataRes.fold( (failure) => studentId = null, (userData) { - studentId = userData.studentId; - code = userData.code; + var student = userData.students + .firstWhereOrNull((element) => element.status == 'активный'); + student ??= userData.students.first; + + studentId = student.id; + code = student.code; }, ); diff --git a/lib/presentation/bloc/user_bloc/user_bloc.dart b/lib/presentation/bloc/user_bloc/user_bloc.dart index a9ae575d..b6510ad0 100644 --- a/lib/presentation/bloc/user_bloc/user_bloc.dart +++ b/lib/presentation/bloc/user_bloc/user_bloc.dart @@ -1,11 +1,13 @@ 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/user.dart'; import 'package:rtu_mirea_app/domain/usecases/get_auth_token.dart'; import 'package:rtu_mirea_app/domain/usecases/get_user_data.dart'; import 'package:rtu_mirea_app/domain/usecases/log_in.dart'; import 'package:rtu_mirea_app/domain/usecases/log_out.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; part 'user_event.dart'; part 'user_state.dart'; @@ -29,6 +31,11 @@ class UserBloc extends Bloc { on<_GetUserData>(_onGetUserDataEvent); } + void _setSentryUserIdentity(String id, String email, String group) { + Sentry.configureScope((scope) => scope + .setUser(SentryUser(id: id, email: email, data: {'group': group}))); + } + void _onLogInEvent( UserEvent event, Emitter emit, @@ -56,6 +63,12 @@ class UserBloc extends Bloc { (failure) => emit(const _Unauthorized()), (user) { FirebaseAnalytics.instance.logLogin(); + var student = user.students + .firstWhereOrNull((element) => element.status == 'активный'); + student ??= user.students.first; + + _setSentryUserIdentity( + user.id.toString(), user.login, student.academicGroup); emit(_LogInSuccess(user)); }, ); @@ -85,9 +98,13 @@ class UserBloc extends Bloc { emit(const _Loading()); final user = await getUserData(); - user.fold( - (failure) => emit(const _Unauthorized()), - (r) => emit(_LogInSuccess(r)), - ); + user.fold((failure) => emit(const _Unauthorized()), (r) { + var student = r.students + .firstWhereOrNull((element) => element.status == 'активный'); + student ??= r.students.first; + + _setSentryUserIdentity(r.id.toString(), r.login, student.academicGroup); + emit(_LogInSuccess(r)); + }); } } diff --git a/lib/presentation/pages/home_page.dart b/lib/presentation/pages/home_page.dart index 288b55d1..31028b1c 100644 --- a/lib/presentation/pages/home_page.dart +++ b/lib/presentation/pages/home_page.dart @@ -21,7 +21,9 @@ class HomePage extends StatelessWidget { MapRoute(), ProfileRouter() ], - navigatorObservers: () => [HeroController(), AutoRouteObserver()], + navigatorObservers: () => [ + HeroController(), + ], bottomNavigationBuilder: (context, tabsRouter) { return AppBottomNavigationBar( index: tabsRouter.activeIndex, diff --git a/lib/presentation/pages/map/map_page.dart b/lib/presentation/pages/map/map_page.dart index 76c2f9a9..adbdf3cb 100644 --- a/lib/presentation/pages/map/map_page.dart +++ b/lib/presentation/pages/map/map_page.dart @@ -35,6 +35,9 @@ class _MapPageState extends State { final defaultScale = 11.0; final minScale = 1.0; + Offset _dragGesturePositon = Offset.zero; + bool _showMagnifier = false; + final floors = [ SvgPicture.asset('assets/map/floor_0.svg'), SvgPicture.asset('assets/map/floor_1.svg'), @@ -53,7 +56,38 @@ class _MapPageState extends State { width: double.infinity, color: AppTheme.colors.background01, ), - _buildMap(), + + Stack( + children: [ + GestureDetector( + onLongPressMoveUpdate: (details) => setState( + () { + _dragGesturePositon = details.localPosition; + }, + ), + onLongPressStart: (_) => setState(() => _showMagnifier = true), + onLongPressEnd: (_) => setState(() => _showMagnifier = false), + child: _buildMap(), + ), + if (_showMagnifier) + Positioned( + left: _dragGesturePositon.dx, + top: _dragGesturePositon.dy, + child: RawMagnifier( + decoration: MagnifierDecoration( + shape: CircleBorder( + side: BorderSide( + color: AppTheme.colors.deactive, + width: 1, + ), + ), + ), + size: const Size(100, 100), + magnificationScale: 2, + ), + ), + ], + ), Align( alignment: Alignment.bottomLeft, child: Padding( diff --git a/lib/presentation/pages/profile/about_app_page.dart b/lib/presentation/pages/profile/about_app_page.dart index 07c462ff..70207131 100644 --- a/lib/presentation/pages/profile/about_app_page.dart +++ b/lib/presentation/pages/profile/about_app_page.dart @@ -1,10 +1,12 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:rtu_mirea_app/presentation/bloc/about_app_bloc/about_app_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/feedback_modal.dart'; import 'package:rtu_mirea_app/service_locator.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; @@ -27,9 +29,10 @@ class AboutAppPage extends StatelessWidget { child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -88,208 +91,237 @@ class AboutAppPage extends StatelessWidget { ), ), ), - ]), - const SizedBox(height: 8), - Text( - 'Это приложение и все относящиеся к нему сервисы являются 100% бесплатными и Open Source продуктами. Мы с огромным удовольствием примем любые ваши предложения и сообщения, а также мы рады любому вашему участию в проекте!', - style: AppTextStyle.bodyRegular, - ), - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: 'Карта для приложения взята из сервиса ', - style: AppTextStyle.bodyRegular.copyWith( - color: AppTheme.colors.active, - ), - ), - TextSpan( - text: 'Indoor Schemes', - style: AppTextStyle.bodyRegular - .copyWith(color: AppTheme.colors.primary), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrlString('https://ischemes.ru/'); - }, - ), ], ), - ), - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: 'Все новости берутся из официального сайта ', - style: AppTextStyle.bodyRegular.copyWith( - color: AppTheme.colors.active, + const SizedBox(height: 8), + Text( + 'Это приложение и все относящиеся к нему сервисы являются ' + '100% бесплатными и Open Source продуктами. Мы с огромным ' + 'удовольствием примем любые ваши предложения и сообщения, а ' + 'также мы рады любому вашему участию в проекте!', + style: AppTextStyle.bodyRegular, + ), + const SizedBox(height: 8), + const Text( + "Спасибо Анне Степушкиной, заместителю председателя по " + "работе со студентами ИПТИП, за её невероятную помощь в " + "разработке карт зданий для нашего приложения."), + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: + 'Есть предложения по улучшению приложения? Напишите' + ' нам на ', + style: AppTextStyle.bodyRegular.copyWith( + color: AppTheme.colors.active, + ), ), - ), - TextSpan( - text: 'mirea.ru/news', - style: AppTextStyle.bodyRegular - .copyWith(color: AppTheme.colors.primary), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrlString('https://mirea.ru/news/'); - }, - ), - ], + TextSpan( + text: 'почту ', + style: AppTextStyle.bodyRegular + .copyWith(color: AppTheme.colors.primary), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlString('mailto:contact@mirea.ninja'); + }, + ), + TextSpan( + text: 'или в Телеграм ', + style: AppTextStyle.bodyRegular + .copyWith(color: AppTheme.colors.active), + ), + TextSpan( + text: 't.me/mirea_ninja_chat', + style: AppTextStyle.bodyRegular + .copyWith(color: AppTheme.colors.primary), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlString( + 'https://t.me/mirea_ninja_chat', + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), ), - ), - const SizedBox(height: 8), - Text( - 'Связаться с нами вы можете с помощью email: contact@mirea.ninja', - style: AppTextStyle.bodyRegular, - ), - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: 'Powered by ', - style: AppTextStyle.bodyRegular.copyWith( - color: AppTheme.colors.active, + const SizedBox(height: 16), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: 'Приложение разработано командой ', + style: AppTextStyle.bodyRegular.copyWith( + color: AppTheme.colors.active, + ), ), + TextSpan( + text: 'Mirea Ninja', + style: AppTextStyle.bodyRegular + .copyWith(color: AppTheme.colors.primary), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlString("https://mirea.ninja/"); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: 40, + width: 90, + child: SocialIconButton( + assetImage: + const AssetImage('assets/icons/github.png'), + onClick: () { + launchUrlString( + 'https://github.com/mirea-ninja/rtu-mirea-mobile', + mode: LaunchMode.externalApplication, + ); + }), ), - TextSpan( - text: 'Mirea Ninja', - style: AppTextStyle.bodyRegular - .copyWith(color: AppTheme.colors.primary), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrlString("https://mirea.ninja/"); - }, + // const SizedBox(width: 12), + // SizedBox( + // height: 40, + // width: 90, + // child: SocialIconButton( + // assetImage: + // const AssetImage('assets/icons/patreon.png'), + // onClick: () { + // launchUrlString( + // 'https://www.patreon.com/mireaninja'); + // }), + // ), + const SizedBox(width: 12), + SizedBox( + height: 40, + width: 90, + child: SocialIconButton( + assetImage: + const AssetImage('assets/icons/telegram.png'), + onClick: () { + launchUrlString( + 'https://t.me/mirea_ninja_chat/1', + mode: LaunchMode.externalApplication, + ); + }), ), ], ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - height: 40, - width: 90, - child: SocialIconButton( - assetImage: const AssetImage('assets/icons/github.png'), - onClick: () { - launchUrlString( - 'https://github.com/Ninja-Official/rtu-mirea-mobile'); - }), - ), - const SizedBox(width: 12), - SizedBox( - height: 40, - width: 90, - child: SocialIconButton( - assetImage: - const AssetImage('assets/icons/patreon.png'), - onClick: () { - launchUrlString('https://www.patreon.com/mireaninja'); - }), - ), - const SizedBox(width: 12), - SizedBox( - height: 40, - width: 90, - child: SocialIconButton( - assetImage: - const AssetImage('assets/icons/telegram.png'), - onClick: () { - launchUrlString( - 'https://t.me/joinchat/LyM7jcoRXUhmOGM6'); - }), + + const SizedBox(height: 24), + Text('Участники проекта', style: AppTextStyle.h4), + const SizedBox(height: 16), + BlocBuilder( + buildWhen: (prevState, currentState) { + if (prevState is AboutAppMembersLoadError) { + if (prevState.contributorsLoadError) return false; + } + return true; + }, + builder: (context, state) { + if (state is AboutAppMembersLoading) { + return Center( + child: CircularProgressIndicator( + backgroundColor: AppTheme.colors.primary, + strokeWidth: 5, + ), + ); + } else if (state is AboutAppMembersLoaded) { + return Wrap( + spacing: 16.0, + runSpacing: 16.0, + children: [ + for (var contributor in state.contributors) + MemberInfo( + username: contributor.login, + avatarUrl: contributor.avatarUrl, + profileUrl: contributor.htmlUrl, + ), + ], + ); + } else if (state is AboutAppMembersLoadError) { + return Center( + child: Text( + 'Произошла ошибка при загрузке разработчиков. Проверьте ваше интернет-соединение.', + style: AppTextStyle.bodyRegular, + ), + ); + } + return Container(); + }, + ), + const SizedBox(height: 16), + SizedBox( + height: 40, + width: double.infinity, + child: ColorfulButton( + text: 'Сообщить об ошибке', + backgroundColor: AppTheme.colors.colorful07.withBlue(180), + onClick: () { + final userBloc = context.read(); + + userBloc.state.maybeMap( + logInSuccess: (value) => FeedbackBottomModalSheet.show( + context, + defaultEmail: value.user.email, + ), + orElse: () => FeedbackBottomModalSheet.show(context), + ); + }, ), - ], - ), - const SizedBox(height: 24), - Text('Контрибьюторы', style: AppTextStyle.h4), - const SizedBox(height: 16), - BlocBuilder( - buildWhen: (prevState, currentState) { - if (prevState is AboutAppMembersLoadError) { - if (prevState.contributorsLoadError) return false; - } - return true; - }, - builder: (context, state) { - if (state is AboutAppMembersLoading) { - return Center( - child: CircularProgressIndicator( - backgroundColor: AppTheme.colors.primary, - strokeWidth: 5, - ), - ); - } else if (state is AboutAppMembersLoaded) { - return Wrap( - spacing: 16.0, - runSpacing: 16.0, - children: [ - for (var contributor in state.contributors) - MemberInfo( - username: contributor.login, - avatarUrl: contributor.avatarUrl, - profileUrl: contributor.htmlUrl, - ), - ], - ); - } else if (state is AboutAppMembersLoadError) { - return Center( - child: Text( - 'Произошла ошибка при загрузке разработчиков. Проверьте ваше интернет-соединение.', - style: AppTextStyle.title, - ), - ); - } - return Container(); - }, - ), - const SizedBox(height: 24), - Text('Патроны', style: AppTextStyle.h4), - const SizedBox(height: 16), - BlocBuilder( - buildWhen: (prevState, currentState) { - if (prevState is AboutAppMembersLoadError) { - if (prevState.patronsLoadError) return false; - } - return true; - }, - builder: (context, state) { - if (state is AboutAppMembersLoading) { - return Center( - child: CircularProgressIndicator( - backgroundColor: AppTheme.colors.primary, - strokeWidth: 5, - ), - ); - } else if (state is AboutAppMembersLoaded) { - return Wrap( - spacing: 16.0, - runSpacing: 16.0, - children: List.generate(state.patrons.length, (index) { - return MemberInfo( - username: state.patrons[index].username, - avatarUrl: - 'https://mirea.ninja/${state.patrons[index].avatarTemplate.replaceAll('{size}', '120')}', - profileUrl: - 'https://mirea.ninja/u/${state.patrons[index].username}', - ); - }), - ); - } else if (state is AboutAppMembersLoadError) { - return Center( - child: Text( - 'Произошла ошибка при загрузке патронов.', - style: AppTextStyle.title, - ), - ); - } - return Container(); - }, - ) - ]), + ), + const SizedBox(height: 24), + // Text('Патроны', style: AppTextStyle.h4), + // const SizedBox(height: 16), + // BlocBuilder( + // buildWhen: (prevState, currentState) { + // if (prevState is AboutAppMembersLoadError) { + // if (prevState.patronsLoadError) return false; + // } + // return true; + // }, + // builder: (context, state) { + // if (state is AboutAppMembersLoading) { + // return Center( + // child: CircularProgressIndicator( + // backgroundColor: AppTheme.colors.primary, + // strokeWidth: 5, + // ), + // ); + // } else if (state is AboutAppMembersLoaded) { + // return Wrap( + // spacing: 16.0, + // runSpacing: 16.0, + // children: List.generate(state.patrons.length, (index) { + // return MemberInfo( + // username: state.patrons[index].username, + // avatarUrl: + // 'https://mirea.ninja/${state.patrons[index].avatarTemplate.replaceAll('{size}', '120')}', + // profileUrl: + // 'https://mirea.ninja/u/${state.patrons[index].username}', + // ); + // }), + // ); + // } else if (state is AboutAppMembersLoadError) { + // return Center( + // child: Text( + // 'Произошла ошибка при загрузке патронов.', + // style: AppTextStyle.title, + // ), + // ); + // } + // return Container(); + // }, + // ) + ], + ), ), ), ), diff --git a/lib/presentation/pages/profile/profile_detail_page.dart b/lib/presentation/pages/profile/profile_detail_page.dart index 8f952f7d..6833a4ba 100644 --- a/lib/presentation/pages/profile/profile_detail_page.dart +++ b/lib/presentation/pages/profile/profile_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:rtu_mirea_app/domain/entities/user.dart'; import 'package:rtu_mirea_app/presentation/widgets/badged_container.dart'; @@ -16,6 +17,9 @@ class ProfileDetailPage extends StatelessWidget { @override Widget build(BuildContext context) { + var student = user.students + .firstWhereOrNull((element) => element.status == 'активный'); + student ??= user.students.first; return Scaffold( appBar: AppBar( title: const Text("Детали профиля"), @@ -59,28 +63,28 @@ class ProfileDetailPage extends StatelessWidget { width: 160, child: BadgedContainer( label: 'Группа', - text: user.academicGroup, + text: student.academicGroup, onClick: () {}), ), SizedBox( width: 160, child: BadgedContainer( label: 'Личный номер', - text: user.personalNumber, + text: student.personalNumber, onClick: () {}), ), SizedBox( width: 160, child: BadgedContainer( label: 'Курс', - text: user.course.toString(), + text: student.course.toString(), onClick: () {}), ), SizedBox( width: 160, child: BadgedContainer( label: 'Состояние', - text: user.isActive ? 'активный' : 'неактивный', + text: student.status, onClick: () {}), ), ]), @@ -102,11 +106,11 @@ class ProfileDetailPage extends StatelessWidget { const SizedBox(height: 23), CopyTextBlockWithLabel( label: "Дата начала обучения", - text: user.educationStartDate), + text: student.educationStartDate), const SizedBox(height: 23), CopyTextBlockWithLabel( label: "Дата окончания обучения", - text: user.educationEndDate), + text: student.educationEndDate), const SizedBox(height: 23), CopyTextBlockWithLabel( label: "Дата регистрации", text: user.registerDate), @@ -115,21 +119,24 @@ class ProfileDetailPage extends StatelessWidget { label: "Последний вход", text: user.lastLoginDate), const SizedBox(height: 23), CopyTextBlockWithLabel( - label: "Формирующее подразделение", text: user.department), + label: "Формирующее подразделение", + text: student.eduProgram.department), const SizedBox(height: 23), CopyTextBlockWithLabel( label: "Выпускающее подразделение", - text: user.prodDepartment), + text: student.eduProgram.prodDepartment), const SizedBox(height: 23), CopyTextBlockWithLabel( label: "Направление подготовки (специальность)", - text: user.eduProgram), + text: student.eduProgram.eduProgram), const SizedBox(height: 23), CopyTextBlockWithLabel( - label: "Код направления", text: user.eduProgramCode), + label: "Код направления", + text: student.eduProgram.eduProgramCode), const SizedBox(height: 23), CopyTextBlockWithLabel( - label: "Вид образовательной программы", text: user.type), + label: "Вид образовательной программы", + text: student.eduProgram.type ?? ""), ], ), ), diff --git a/lib/presentation/pages/profile/profile_nfc_pass_page.dart b/lib/presentation/pages/profile/profile_nfc_pass_page.dart index 779598d1..653b68d1 100644 --- a/lib/presentation/pages/profile/profile_nfc_pass_page.dart +++ b/lib/presentation/pages/profile/profile_nfc_pass_page.dart @@ -58,7 +58,11 @@ class _ProfileNfcPageState extends State { return state.maybeMap( logInSuccess: (state) { - final student = state.user; + final user = state.user; + var student = user.students.firstWhereOrNull( + (element) => element.status == 'активный'); + student ??= user.students.first; + return ListView( children: [ const SizedBox(height: 24), @@ -94,8 +98,8 @@ class _ProfileNfcPageState extends State { initial: (_) { context.read().add( NfcPassEvent.getNfcPasses( - student.code, - student.studentId, + student!.code, + student.id, snapshot.data!.id, ), ); @@ -118,8 +122,8 @@ class _ProfileNfcPageState extends State { onPressed: () => context.read().add( NfcPassEvent.connectNfcPass( - student.code, - student.studentId, + student!.code, + student.id, snapshot.data!.id, snapshot.data!.model, ), @@ -173,8 +177,8 @@ class _ProfileNfcPageState extends State { onClick: () { context.read().add( NfcPassEvent.connectNfcPass( - student.code, - student.studentId, + student!.code, + student.id, snapshot.data!.id, snapshot.data!.model, ), @@ -210,7 +214,7 @@ class _ProfileNfcPageState extends State { builder: (context, state) => state.map( initial: (_) { final fullName = - "${student.name} ${student.secondName.replaceAll(" ", "").isNotEmpty ? "${student.secondName} " : ""}${student.lastName}"; + "${user.name} ${user.secondName.replaceAll(" ", "").isNotEmpty ? "${user.secondName} " : ""}${user.lastName}"; return _NfcPassNotExistOnAccount( onClick: () => context @@ -218,7 +222,7 @@ class _ProfileNfcPageState extends State { .add( NfcFeedbackEvent.sendFeedback( fullName: fullName, - group: student.academicGroup, + group: student!.academicGroup, personalNumber: student.personalNumber, studentId: @@ -226,7 +230,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 0c50a2a3..6f457d6e 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -112,10 +112,7 @@ class _UserLoggedInView extends StatelessWidget { child: SocialIconButton( assetImage: const AssetImage('assets/icons/gerb.ico'), onClick: () { - launchUrlString( - user.authShortlink != null - ? "https://lk.mirea.ru/auth/link/?url=${user.authShortlink!}" - : "https://lk.mirea.ru/auth", + launchUrlString("https://lk.mirea.ru/auth", mode: LaunchMode.externalApplication); }, text: "Вход в ЛКС", diff --git a/lib/presentation/pages/profile/profile_scores_page.dart b/lib/presentation/pages/profile/profile_scores_page.dart index d2908c27..370134ca 100644 --- a/lib/presentation/pages/profile/profile_scores_page.dart +++ b/lib/presentation/pages/profile/profile_scores_page.dart @@ -106,6 +106,13 @@ class _ProfileScoresPageState extends State { Row(children: [ IconButton( onPressed: () => showModalBottomSheet( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + backgroundColor: + AppTheme.colors.background02, context: context, builder: (BuildContext context) => ScoresChartModal(scores: state.scores), diff --git a/lib/presentation/pages/profile/widgets/scores_chart_modal.dart b/lib/presentation/pages/profile/widgets/scores_chart_modal.dart index dce7721c..343577fb 100644 --- a/lib/presentation/pages/profile/widgets/scores_chart_modal.dart +++ b/lib/presentation/pages/profile/widgets/scores_chart_modal.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:rtu_mirea_app/domain/entities/score.dart'; +import 'package:rtu_mirea_app/presentation/typography.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; import 'package:rtu_mirea_app/presentation/theme.dart'; @@ -12,9 +13,39 @@ class ScoresChartModal extends StatefulWidget { State createState() => _ScoresChartModalState(); } +enum _TrendlineTypeCustom { + linear, + exponential, + power, + logarithmic, + polynomial, + movingAverage, + none, +} + class _ScoresChartModalState extends State { late List<_ChartData> chartData; + TrendlineType _getTrendlineType(_TrendlineTypeCustom type) { + switch (type) { + case _TrendlineTypeCustom.linear: + return TrendlineType.linear; + case _TrendlineTypeCustom.exponential: + return TrendlineType.exponential; + case _TrendlineTypeCustom.power: + return TrendlineType.power; + case _TrendlineTypeCustom.logarithmic: + return TrendlineType.logarithmic; + case _TrendlineTypeCustom.polynomial: + return TrendlineType.polynomial; + case _TrendlineTypeCustom.movingAverage: + return TrendlineType.movingAverage; + case _TrendlineTypeCustom.none: + break; + } + return TrendlineType.linear; + } + int _getScoreByName(String name) { switch (name.toLowerCase()) { case "отлично": @@ -41,7 +72,7 @@ class _ScoresChartModalState extends State { double average = 0; for (final score in scores) { final scoreValue = _getScoreByName(score.result); - + if (scoreValue != -1) { count++; average += scoreValue; @@ -49,7 +80,7 @@ class _ScoresChartModalState extends State { } average = average / count; - rating[semester] = average; + rating[semester] = double.parse(average.toStringAsFixed(2)); } return rating; @@ -71,34 +102,148 @@ class _ScoresChartModalState extends State { super.dispose(); } + _TrendlineTypeCustom _trendlineType = _TrendlineTypeCustom.none; + @override Widget build(BuildContext context) { - return SfCartesianChart( - backgroundColor: AppTheme.colors.background02, - plotAreaBorderWidth: 0, - title: ChartTitle(text: 'Средний балл успеваемости'), - legend: - Legend(isVisible: true, overflowMode: LegendItemOverflowMode.wrap), - primaryXAxis: NumericAxis( - labelFormat: '{value} семестр', + // Add border radius to top left and top right. Container decoration does not work + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: SfCartesianChart( + backgroundColor: AppTheme.colors.background02, + plotAreaBorderWidth: 0, + title: ChartTitle( + text: 'Успеваемость', + textStyle: AppTextStyle.titleS, + alignment: ChartAlignment.center, + ), + plotAreaBorderColor: AppTheme.colors.active.withOpacity(0.05), + borderWidth: 0, + legend: Legend( + isVisible: true, + overflowMode: LegendItemOverflowMode.wrap, + borderColor: Colors.transparent, + position: LegendPosition.bottom, + textStyle: AppTextStyle.bodyBold, + iconHeight: 10, + iconWidth: 10, + legendItemBuilder: (legendText, series, point, seriesIndex) => + Container( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row(children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: series.color, + borderRadius: const BorderRadius.all(Radius.circular(5)), + ), + ), + const SizedBox(width: 8), + Text( + legendText, + style: AppTextStyle.bodyBold.copyWith( + color: AppTheme.colors.active.withOpacity(0.8), + ), + ), + ]), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + setState(() { + _trendlineType = _TrendlineTypeCustom.values[ + (_TrendlineTypeCustom.values.indexOf(_trendlineType) + + 1) % + _TrendlineTypeCustom.values.length]; + }); + }, + child: Row(children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: AppTheme.colors.active.withOpacity(0.1), + borderRadius: + const BorderRadius.all(Radius.circular(10)), + ), + child: Icon( + Icons.trending_up, + size: 16, + color: AppTheme.colors.active, + ), + ), + const SizedBox(width: 8), + Text( + _trendlineType.toString().split('.').last, + style: AppTextStyle.bodyBold.copyWith( + color: AppTheme.colors.active.withOpacity(0.8), + ), + ), + ]), + ), + ], + ), + ), + ), + primaryXAxis: NumericAxis( + labelFormat: '{value} сем.', + interval: 0.5, + axisLine: const AxisLine(width: 0), + labelStyle: + AppTextStyle.chip.copyWith(color: AppTheme.colors.deactive), + minimum: 0.5, + majorTickLines: const MajorTickLines(color: Colors.transparent), edgeLabelPlacement: EdgeLabelPlacement.shift, - interval: 1, - majorGridLines: const MajorGridLines(width: 0)), - primaryYAxis: NumericAxis( + majorGridLines: const MajorGridLines(width: 0), + axisLabelFormatter: (axisLabelRenderArgs) => ChartAxisLabel( + axisLabelRenderArgs.value % 1 == 0 ? axisLabelRenderArgs.text : '', + AppTextStyle.chip.copyWith( + color: AppTheme.colors.deactive, + ), + ), + ), + primaryYAxis: NumericAxis( labelFormat: '{value}', axisLine: const AxisLine(width: 0), - majorTickLines: const MajorTickLines(color: Colors.transparent)), - series: [ - LineSeries<_ChartData, num>( - animationDuration: 2500, + labelStyle: + AppTextStyle.chip.copyWith(color: AppTheme.colors.deactive), + maximum: 5, + majorTickLines: const MajorTickLines(color: Colors.transparent), + ), + series: [ + ColumnSeries<_ChartData, num>( + trendlines: [ + if (_trendlineType != _TrendlineTypeCustom.none) + Trendline( + type: _getTrendlineType(_trendlineType), + color: AppTheme.colors.colorful01.withOpacity(0.2), + dashArray: [5, 5], + width: 2, + ), + ], + borderRadius: const BorderRadius.all(Radius.circular(8)), + animationDuration: 1500, dataSource: chartData, xValueMapper: (_ChartData sales, _) => sales.x, yValueMapper: (_ChartData sales, _) => sales.y, - width: 2, + width: 0.08, + color: AppTheme.colors.colorful01, name: 'Средний балл', - markerSettings: const MarkerSettings(isVisible: true)), - ], - tooltipBehavior: TooltipBehavior(enable: true), + markerSettings: const MarkerSettings(isVisible: false), + dataLabelSettings: DataLabelSettings( + isVisible: true, + textStyle: AppTextStyle.bodyBold.copyWith( + color: AppTheme.colors.active.withOpacity(0.8), + ), + ), + ), + ], + tooltipBehavior: TooltipBehavior(enable: true), + ), ); } } diff --git a/lib/presentation/pages/schedule/schedule_page.dart b/lib/presentation/pages/schedule/schedule_page.dart index 9f8e3d7d..36cf73cf 100644 --- a/lib/presentation/pages/schedule/schedule_page.dart +++ b/lib/presentation/pages/schedule/schedule_page.dart @@ -4,12 +4,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.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/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/settings_switch_button.dart'; +import '../../widgets/feedback_modal.dart'; import 'widgets/schedule_page_view.dart'; import 'package:rtu_mirea_app/presentation/typography.dart'; @@ -263,6 +265,60 @@ class _SchedulePageState extends State { 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), + ], + ), + ), + 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; + + final userBloc = context.read(); + + userBloc.state.maybeMap( + logInSuccess: (value) => + FeedbackBottomModalSheet.show( + context, + defaultText: defaultText, + defaultEmail: value.user.email, + ), + orElse: () => FeedbackBottomModalSheet.show( + context, + defaultText: defaultText, + ), + ); + }, + ), + ), + if (state is ScheduleLoaded) Expanded( child: Column( diff --git a/lib/presentation/widgets/feedback_modal.dart b/lib/presentation/widgets/feedback_modal.dart new file mode 100644 index 00000000..95f81afe --- /dev/null +++ b/lib/presentation/widgets/feedback_modal.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:sentry/sentry.dart'; + +import '../theme.dart'; +import '../typography.dart'; +import 'buttons/primary_button.dart'; + +class FeedbackBottomModalSheet extends StatefulWidget { + const FeedbackBottomModalSheet({ + Key? key, + this.onConfirm, + this.defaultText, + this.defaultEmail, + }) : super(key: key); + + final String? defaultEmail; + final String? defaultText; + final VoidCallback? onConfirm; + + static void show( + BuildContext context, { + String? defaultEmail, + String? defaultText, + VoidCallback? onConfirm, + }) { + showModalBottomSheet( + isDismissible: true, + isScrollControlled: true, + backgroundColor: AppTheme.colors.background02, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + context: context, + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: FeedbackBottomModalSheet( + defaultEmail: defaultEmail, + defaultText: defaultText, + onConfirm: () { + onConfirm?.call(); + Navigator.pop(context); + }, + ), + ), + ), + ); + } + + @override + State createState() => + _FeedbackBottomModalSheetState(); +} + +class _FeedbackBottomModalSheetState extends State { + @override + void initState() { + super.initState(); + _emailController.text = widget.defaultEmail ?? ''; + _textController.text = widget.defaultText ?? ''; + } + + final _emailController = TextEditingController(); + final _textController = TextEditingController(); + + String? _emailErrorText; + String? _textErrorText; + + final _reEmail = RegExp( + r'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$', + ); + + void _sendFeedback() async { + final email = _emailController.text; + final text = _textController.text; + + if (email == null) { + setState(() { + _emailErrorText = 'Введите email'; + }); + return; + } + + if (text == null) { + setState(() { + _textErrorText = 'Введите текст'; + }); + return; + } + + if (!_reEmail.hasMatch(email)) { + setState(() { + _emailErrorText = 'Некорректный email'; + }); + return; + } + + if (text.isEmpty) { + setState(() { + _textErrorText = 'Введите текст'; + }); + return; + } + + setState(() { + _emailErrorText = null; + _textErrorText = null; + }); + + final SentryId sentryId = await Sentry.captureMessage(text); + + final userFeedback = SentryUserFeedback( + eventId: sentryId, + email: email, + comments: text, + ); + + Sentry.captureUserFeedback(userFeedback).then((value) { + final message = 'Отзыв отправлен. Код ошибки: $sentryId'; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: AppTheme.colors.primary.withOpacity(0.8), + content: Text(message, style: AppTextStyle.captionL), + duration: const Duration(seconds: 3), + ), + ); + }); + widget.onConfirm?.call(); + } + + @override + Widget build(BuildContext context) { + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Colors.transparent, + width: 0, + ), + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Text( + 'Оставить отзыв', + style: AppTextStyle.h5, + ), + const SizedBox(height: 8), + Text( + 'Кажется, у вас что-то пошло не так. Пожалуйста, напишите нам, и мы постараемся исправить это. Мы свяжемся по указанному email адресу для уточнения деталей.', + style: AppTextStyle.captionL.copyWith( + color: AppTheme.colors.deactive, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ]), + Text( + 'Email', + style: AppTextStyle.chip.copyWith( + color: AppTheme.colors.deactive, + ), + ), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration( + errorText: _emailErrorText, + errorStyle: AppTextStyle.captionL.copyWith( + color: AppTheme.colors.colorful07, + ), + hintText: 'Введите email', + hintStyle: AppTextStyle.titleS.copyWith( + color: AppTheme.colors.deactive, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.primary, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.colorful07, + ), + ), + disabledBorder: border, + enabledBorder: border, + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.colorful07, + ), + ), + fillColor: AppTheme.colors.background01, + filled: true, + ), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + style: AppTextStyle.titleS, + controller: _emailController, + ), + const SizedBox(height: 24), + Text( + 'Что случилось?', + style: AppTextStyle.chip.copyWith( + color: AppTheme.colors.deactive, + ), + ), + const SizedBox(height: 8), + TextField( + keyboardType: TextInputType.multiline, + maxLines: 5, + controller: _textController, + decoration: InputDecoration( + hintText: 'Когда я нажимаю "Х" происходит "У"', + hintStyle: AppTextStyle.bodyL.copyWith( + color: AppTheme.colors.deactive, + ), + errorText: _textErrorText, + errorStyle: AppTextStyle.captionS.copyWith( + color: AppTheme.colors.colorful07, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.primary, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.colorful07, + ), + ), + disabledBorder: border, + enabledBorder: border, + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: AppTheme.colors.colorful07, + ), + ), + fillColor: AppTheme.colors.background01, + filled: true, + ), + textInputAction: TextInputAction.done, + style: AppTextStyle.bodyL, + ), + const SizedBox(height: 24), + PrimaryButton( + text: 'Отправить', + onClick: _sendFeedback, + ), + ], + ), + ); + } +} diff --git a/lib/service_locator.dart b/lib/service_locator.dart index d05ebb64..f9f3bc53 100644 --- a/lib/service_locator.dart +++ b/lib/service_locator.dart @@ -239,7 +239,8 @@ Future setup() async { // Common / Core // External Dependency - getIt.registerLazySingleton(() => Dio(BaseOptions(receiveTimeout: 20000))); + final dio = Dio(BaseOptions(receiveTimeout: 20000)); + getIt.registerLazySingleton(() => dio); final sharedPreferences = await SharedPreferences.getInstance(); getIt.registerLazySingleton(() => sharedPreferences); const secureStorage = FlutterSecureStorage( diff --git a/pubspec.yaml b/pubspec.yaml index 895631f7..78ced6d1 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.0+15 +version: 1.3.1+22 environment: sdk: ">=2.19.0 <3.0.0" @@ -35,7 +35,7 @@ dependencies: # Http client. # See https://pub.dev/packages/dio - dio: ^4.0.6 + dio: ^4.0.0 # Simple no-sql local database. # See https://pub.dev/packages/shared_preferences @@ -168,10 +168,9 @@ dependencies: freezed_annotation: ^2.1.0 - firebase_core: ^2.4.1 - firebase_core_web: ^2.1.0 - firebase_analytics: ^10.1.0 - firebase_crashlytics: ^3.0.11 + firebase_core: ^2.6.1 + firebase_core_web: ^2.2.1 + firebase_analytics: ^10.1.3 oauth2_client: ^3.1.0 @@ -193,6 +192,11 @@ dependencies: # See https://pub.dev/packages/app_settings app_settings: ^4.2.0 + sentry_flutter: ^6.20.1 + sentry_logging: ^6.20.1 + sentry_dio: ^6.20.1 + logging: ^1.0.2 + dev_dependencies: # The "flutter_lints" package below contains a set of recommended lints to