From 1e4b851f4e434163277a9568d5a6d967829aeeee Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 12 Apr 2024 18:53:53 +0300 Subject: [PATCH 01/88] Bootstrap --- CHANGELOG.md | 17 + android/app/src/main/AndroidManifest.xml | 21 +- assets/l10n/en-US.ftl | 23 + assets/l10n/ru-RU.ftl | 23 + lib/main.dart | 217 +++++---- lib/routes.dart | 13 +- lib/ui/page/auth/view.dart | 14 +- lib/ui/page/erase/controller.dart | 142 ++++++ lib/ui/page/erase/view.dart | 153 ++++++ lib/ui/page/home/introduction/view.dart | 28 +- lib/ui/page/home/page/my_profile/view.dart | 47 +- lib/ui/page/home/router.dart | 9 +- lib/ui/page/home/tab/work/view.dart | 14 - lib/ui/page/login/privacy_policy/view.dart | 102 ++++ lib/ui/page/login/terms_of_use/view.dart | 65 +++ lib/ui/page/login/view.dart | 10 +- .../work/page/freelance/widget/issue.dart | 28 +- lib/ui/widget/markdown.dart | 41 ++ lib/ui/widget/upgrade_popup/view.dart | 30 +- lib/ui/worker/background/background.dart | 224 --------- lib/ui/worker/background/src/main.dart | 453 ------------------ lib/ui/worker/call.dart | 48 +- lib/util/get.dart | 14 + pubspec.lock | 16 +- pubspec.yaml | 2 +- test/widget/auth_test.dart | 2 - web/privacy.html | 124 +++++ web/terms.html | 101 ++++ 28 files changed, 1025 insertions(+), 956 deletions(-) create mode 100644 lib/ui/page/erase/controller.dart create mode 100644 lib/ui/page/erase/view.dart create mode 100644 lib/ui/page/login/privacy_policy/view.dart create mode 100644 lib/ui/page/login/terms_of_use/view.dart create mode 100644 lib/ui/widget/markdown.dart delete mode 100644 lib/ui/worker/background/background.dart delete mode 100644 lib/ui/worker/background/src/main.dart create mode 100644 lib/util/get.dart create mode 100644 web/privacy.html create mode 100644 web/terms.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e7fd75d55..2a28750c622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All user visible changes to this project will be documented in this file. This p +## [0.1.0-alpha.14] · 2024-??-?? +[0.1.0-alpha.14]: /../../tree/v0.1.0-alpha.14 + +[Diff](/../../compare/v0.1.0-alpha.13.2...v0.1.0-alpha.14) | [Milestone](/../../milestone/21) + +### Added + +- UI: + - Account deletion page. ([#944]) + - Terms and conditions. ([#944]) + - Privacy policy. ([#944]) + +[#944]: /../../pull/944 + + + + ## [0.1.0-alpha.13.2] · 2024-04-11 [0.1.0-alpha.13.2]: /../../tree/v0.1.0-alpha.13.2 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 39414f4f820..256c05b393a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,32 +3,25 @@ - - - - - - - - + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> - - - - - diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index da8911cadf8..bfe2a064c21 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -61,6 +61,7 @@ alert_user_will_be_blocked2 = {" "}will be blocked. alert_user_will_be_removed1 = User{" "} alert_user_will_be_removed2 = {" "}will be removed from the group. alert_you_will_leave_group = You will leave the group. +btn_accept = Accept btn_add = Add btn_add_member = Add member btn_add_participant = Add participant @@ -170,6 +171,7 @@ btn_copy = Copy btn_copy_text = Copy text btn_create = Create btn_create_group = Create group +btn_decline = Decline btn_delete = Delete btn_delete_account = Delete account btn_delete_chat = Delete chat @@ -220,6 +222,7 @@ btn_participants_desc = btn_password = Password btn_paste = Paste btn_personalize = Personalization +btn_privacy_policy = Privacy policy btn_proceed = Proceed btn_remove = Remove btn_rename = Rename @@ -250,6 +253,7 @@ btn_sticker = Sticker btn_submit = Submit btn_take_photo = Take photo btn_take_video = Take video +btn_terms_and_conditions = Terms and conditions btn_unblock = Unblock btn_unblock_short = Unblock btn_undo_delete = Undo delete @@ -861,6 +865,25 @@ label_password_not_set_info = No password has been set for your account. Consequ • if you use the web version, access to your account will be lost forever when you close the browser window and delete cookies. label_password_set = Password has been set. +label_personal_data_deletion = Delete account +label_personal_data_deletion_authorize = In order to delete your account, please, authorize first in the form below. +label_personal_data_deletion_description = + Account deletion can be requested from this page. This process in IRREVERSIBLE and you will never be able to restore your account. + + The data that will be deleted is: + - your avatar; + - your name; + - your biography; + - your login; + - all of your emails; + - all of your phone numbers; + - your contacts list. + + The data that will not be deleted: + - your Gapopa ID, as is does not represent personal information; + - the messages you have sent, however no one will see you as an author of those messages. + + Not a single user will be able to find, identify or detect the information of your presence within the system. label_personalization = Personalization label_phone = Phone label_phone_confirmation_code_was_send = diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index cf5521d5f89..d609213a91f 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -61,6 +61,7 @@ alert_user_will_be_blocked2 = {" "}будет заблокирован. alert_user_will_be_removed1 = Пользователь{" "} alert_user_will_be_removed2 = {" "}будет удалён из группы. alert_you_will_leave_group = Вы покинете группу. +btn_accept = Принять btn_add = Добавить btn_add_member = Добавить участника btn_add_participant = Добавить участника @@ -170,6 +171,7 @@ btn_copy = Скопировать btn_copy_text = Скопировать текст btn_create = Создать btn_create_group = Создать группу +btn_decline = Отклонить btn_delete = Удалить btn_delete_account = Удалить аккаунт btn_delete_chat = Удалить чат @@ -220,6 +222,7 @@ btn_participants_desc = btn_password = Пароль btn_paste = Вставить btn_personalize = Персонализация +btn_privacy_policy = Политика конфиденциальности btn_proceed = Продолжить btn_remove = Удалить btn_rename = Переименовать @@ -250,6 +253,7 @@ btn_sticker = Стикер btn_submit = Применить btn_take_photo = Фото btn_take_video = Видео +btn_terms_and_conditions = Условия использования btn_unblock = Разблокировать btn_unblock_short = Разблок. btn_undo_delete = Отменить удаление @@ -888,6 +892,25 @@ label_password_not_set_info = Для Вашего аккаунта не зада • в случае если Вы используете веб-версию, доступ к Вашему аккаунту будет утрачен безвозвратно при закрытии окна браузера и удалении файлов cookie. label_password_set = Пароль задан. +label_personal_data_deletion = Удалить аккаунт +label_personal_data_deletion_authorize = Чтобы удалить Ваш аккаунт, пожалуйста, авторизуйтесь в форме ниже. +label_personal_data_deletion_description = + Запрос на удаление аккаунта может быть отправлен с данной страницы. Этот процесс НЕОБРАТИМ - Вы не сможете восстановить свой аккаунт. + + Информация, которая будет удалена: + - Ваш аватар; + - Ваше имя; + - Ваша биография; + - Ваш логин; + - все Ваши e-mail адреса; + - все Ваши номера телефонов; + - Ваш список контактов. + + Информация, которая не будет удалена: + - Ваш Gapopa ID, поскольку он не является персональной информацией; + - отправленные Вами сообщения, при этом никто не сможет идентифицировать, что автором этих сообщений были или являетесь Вы. + + Ни один пользователь не сможет найти, идентифицировать или обнаружить информацию о Вашем присутствии в системе. label_personalization = Персонализация label_phone = Телефон label_phone_confirmation_code_was_send = diff --git a/lib/main.dart b/lib/main.dart index 121a6121a73..1e12f4e2cce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,11 +23,13 @@ library main; import 'dart:async'; -import 'package:callkeep/callkeep.dart'; import 'package:dio/dio.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:get/get.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:http/http.dart'; @@ -65,6 +67,7 @@ import 'ui/worker/cache.dart'; import 'ui/worker/upgrade.dart'; import 'ui/worker/window.dart'; import 'util/backoff.dart'; +import 'util/get.dart'; import 'util/log.dart'; import 'util/platform_utils.dart'; import 'util/web/web_utils.dart'; @@ -236,110 +239,129 @@ Future main() async { /// Messaging notification background handler. @pragma('vm:entry-point') Future handlePushNotification(RemoteMessage message) async { + await Firebase.initializeApp(); + Log.debug('handlePushNotification($message)', 'main'); if (message.notification?.android?.tag?.endsWith('_call') == true && message.data['chatId'] != null) { - final FlutterCallkeep callKeep = FlutterCallkeep(); - - if (await callKeep.hasPhoneAccount()) { - SharedPreferences? prefs; - CredentialsHiveProvider? credentialsProvider; - GraphQlProvider? provider; - StreamSubscription? subscription; - - try { - await callKeep.setup( - null, - Config.callKeep, - backgroundMode: true, - ); - - callKeep.on( - CallKeepPerformAnswerCallAction(), - (CallKeepPerformAnswerCallAction event) async { + SharedPreferences? prefs; + CredentialsHiveProvider? credentialsProvider; + GraphQlProvider? provider; + StreamSubscription? subscription; + + try { + FlutterCallkitIncoming.onEvent.listen((CallEvent? event) async { + switch (event!.event) { + case Event.actionCallAccept: await prefs?.setString('answeredCall', message.data['chatId']); - await callKeep.rejectCall(event.callUUID!); - await callKeep.backToForeground(); - }, - ); - - callKeep.on( - CallKeepPerformEndCallAction(), - (CallKeepPerformEndCallAction event) async { - if (prefs?.getString('answeredCall') != event.callUUID!) { - await provider?.declineChatCall(ChatId(event.callUUID!)); - } + break; + case Event.actionCallDecline: + await provider?.declineChatCall(ChatId(message.data['chatId'])); + break; + + case Event.actionCallEnded: + case Event.actionCallTimeout: subscription?.cancel(); provider?.disconnect(); await Hive.close(); - }, - ); - - callKeep.displayIncomingCall( - message.data['chatId'], - message.notification?.title ?? 'gapopa', - handleType: 'generic', - ); - - await Config.init(); - await Hive.initFlutter('hive'); - credentialsProvider = CredentialsHiveProvider(); - - await credentialsProvider.init(); - final Credentials? credentials = credentialsProvider.get(); - await credentialsProvider.close(); - - if (credentials != null) { - provider = GraphQlProvider(); - provider.token = credentials.access.secret; - provider.reconnect(); - - subscription = provider - .chatEvents(ChatId(message.data['chatId']), null, () => null) - .listen((e) { - var events = ChatEvents$Subscription.fromJson(e.data!).chatEvents; - if (events.$$typename == 'ChatEventsVersioned') { - var mixin = events - as ChatEvents$Subscription$ChatEvents$ChatEventsVersioned; - - for (var e in mixin.events) { - if (e.$$typename == 'EventChatCallFinished') { - callKeep.rejectCall(message.data['chatId']); - } else if (e.$$typename == 'EventChatCallMemberJoined') { - var node = e - as ChatEventsVersionedMixin$Events$EventChatCallMemberJoined; - if (node.user.id == credentials.userId) { - callKeep.rejectCall(message.data['chatId']); - } - } else if (e.$$typename == 'EventChatCallDeclined') { - var node = e - as ChatEventsVersionedMixin$Events$EventChatCallDeclined; - if (node.user.id == credentials.userId) { - callKeep.rejectCall(message.data['chatId']); - } + break; + + case Event.actionCallCallback: + // TODO: Handle. + break; + + default: + break; + } + }); + + await FlutterCallkitIncoming.showCallkitIncoming( + CallKitParams( + id: message.data['chatId'], + nameCaller: message.notification?.title ?? 'gapopa', + appName: 'Gapopa', + avatar: '', // TODO: Add avatar to FCM notifications. + handle: '0123456789', + type: 0, + textAccept: 'btn_accept'.l10n, + textDecline: 'btn_decline'.l10n, + duration: 30000, + extra: {'chatId': message.data['chatId']}, + headers: {'platform': 'flutter'}, + android: AndroidParams( + isCustomNotification: true, + isShowLogo: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0955fa', + backgroundUrl: '', // TODO: Add avatar to FCM notifications. + actionColor: '#4CAF50', + textColor: '#ffffff', + incomingCallNotificationChannelName: 'label_incoming_call'.l10n, + missedCallNotificationChannelName: 'label_chat_call_missed'.l10n, + isShowCallID: false, + isShowFullLockedScreen: true, + ), + ), + ); + + await Config.init(); + await Hive.initFlutter('hive'); + credentialsProvider = CredentialsHiveProvider(); + + await credentialsProvider.init(); + final Credentials? credentials = credentialsProvider.get(); + await credentialsProvider.close(); + + if (credentials != null) { + provider = GraphQlProvider(); + provider.token = credentials.access.secret; + provider.reconnect(); + + subscription = provider + .chatEvents(ChatId(message.data['chatId']), null, () => null) + .listen((e) async { + var events = ChatEvents$Subscription.fromJson(e.data!).chatEvents; + if (events.$$typename == 'ChatEventsVersioned') { + var mixin = events + as ChatEvents$Subscription$ChatEvents$ChatEventsVersioned; + + for (var e in mixin.events) { + if (e.$$typename == 'EventChatCallFinished') { + await FlutterCallkitIncoming.endCall(message.data['chatId']); + } else if (e.$$typename == 'EventChatCallMemberJoined') { + var node = e + as ChatEventsVersionedMixin$Events$EventChatCallMemberJoined; + if (node.user.id == credentials.userId) { + await FlutterCallkitIncoming.endCall(message.data['chatId']); + } + } else if (e.$$typename == 'EventChatCallDeclined') { + var node = + e as ChatEventsVersionedMixin$Events$EventChatCallDeclined; + if (node.user.id == credentials.userId) { + await FlutterCallkitIncoming.endCall(message.data['chatId']); } } } - }); - - prefs = await SharedPreferences.getInstance(); - await prefs.remove('answeredCall'); - } + } + }); - // Remove the incoming call notification after a reasonable amount of - // time for a better UX. - await Future.delayed(30.seconds); - - callKeep.rejectCall(message.data['chatId']); - } catch (_) { - provider?.disconnect(); - subscription?.cancel(); - callKeep.rejectCall(message.data['chatId']); - await credentialsProvider?.close(); - await Hive.close(); + prefs = await SharedPreferences.getInstance(); + await prefs.remove('answeredCall'); } + + // Remove the incoming call notification after a reasonable amount of + // time for a better UX. + await Future.delayed(30.seconds); + + await FlutterCallkitIncoming.endCall(message.data['chatId']); + } catch (_) { + provider?.disconnect(); + subscription?.cancel(); + await FlutterCallkitIncoming.endCall(message.data['chatId']); + await credentialsProvider?.close(); + await Hive.close(); } } } @@ -439,16 +461,3 @@ extension HiveClean on HiveInterface { } } } - -/// Extension adding ability to find non-strict dependencies from a -/// [GetInterface]. -extension on GetInterface { - /// Returns the [S] dependency, if it [isRegistered]. - S? findOrNull({String? tag}) { - if (isRegistered(tag: tag)) { - return find(tag: tag); - } - - return null; - } -} diff --git a/lib/routes.dart b/lib/routes.dart index 3c00d533418..862c717f02b 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -71,6 +71,7 @@ import 'store/settings.dart'; import 'store/user.dart'; import 'ui/page/auth/view.dart'; import 'ui/page/chat_direct_link/view.dart'; +import 'ui/page/erase/view.dart'; import 'ui/page/home/view.dart'; import 'ui/page/popup_call/view.dart'; import 'ui/page/style/view.dart'; @@ -100,6 +101,7 @@ class Routes { static const menu = '/menu'; static const user = '/user'; static const work = '/work'; + static const erase = '/erase'; // E2E tests related page, should not be used in non-test environment. static const restart = '/restart'; @@ -278,7 +280,7 @@ class RouterState extends ChangeNotifier { /// - [Routes.home] is allowed always. /// - Any other page is allowed to visit only on success auth status. String _guarded(String to) { - if (to.startsWith(Routes.work)) { + if (to.startsWith(Routes.work) || to.startsWith(Routes.erase)) { return to; } @@ -782,6 +784,14 @@ class AppRouterDelegate extends RouterDelegate child: WorkView(), ) ]; + } else if (_state.route.startsWith(Routes.erase)) { + return const [ + MaterialPage( + key: ValueKey('ErasePage'), + name: Routes.erase, + child: EraseView(), + ) + ]; } else { pages.add(const MaterialPage( key: ValueKey('AuthPage'), @@ -794,6 +804,7 @@ class AppRouterDelegate extends RouterDelegate _state.route.startsWith(Routes.contacts) || _state.route.startsWith(Routes.user) || _state.route.startsWith(Routes.work) || + _state.route.startsWith(Routes.erase) || _state.route == Routes.me || _state.route == Routes.home) { _updateTabTitle(); diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index 55ea9f961ed..d5410d9111a 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -46,12 +46,14 @@ class AuthView extends StatelessWidget { builder: (AuthController c) { final Widget status = Column( children: [ - const SizedBox(height: 4), - StyledCupertinoButton( - label: 'btn_download_application'.l10n, - style: style.fonts.normal.regular.secondary, - onPressed: () => _download(context), - ), + if (PlatformUtils.isWeb || !PlatformUtils.isMobile) ...[ + const SizedBox(height: 4), + StyledCupertinoButton( + label: 'btn_download_application'.l10n, + style: style.fonts.normal.regular.secondary, + onPressed: () => _download(context), + ), + ], const SizedBox(height: 4), StyledCupertinoButton( padding: const EdgeInsets.all(8), diff --git a/lib/ui/page/erase/controller.dart b/lib/ui/page/erase/controller.dart new file mode 100644 index 00000000000..2c048047e70 --- /dev/null +++ b/lib/ui/page/erase/controller.dart @@ -0,0 +1,142 @@ +import 'package:get/get.dart'; + +import '/domain/model/my_user.dart'; +import '/domain/model/user.dart'; +import '/domain/service/my_user.dart'; +import '/l10n/l10n.dart'; +import '/provider/gql/exceptions.dart'; +import '/routes.dart'; +import '/ui/widget/text_field.dart'; +import '/util/message_popup.dart'; + +import '/domain/service/auth.dart'; + +/// Controller of the [Routes.erase] page. +class EraseController extends GetxController { + EraseController(this._authService, this._myUserService); + + /// [TextFieldState] of a login text input. + late final TextFieldState login = TextFieldState( + onChanged: (s) => s.error.value = null, + onSubmitted: (s) => password.focus.requestFocus(), + ); + + /// [TextFieldState] of a password text input. + late final TextFieldState password = TextFieldState( + onChanged: (s) => s.error.value = null, + onSubmitted: (s) => signIn(), + ); + + /// Indicator whether the [password] should be obscured. + final RxBool obscurePassword = RxBool(true); + + /// Indicator whether the [newPassword] should be obscured. + final RxBool obscureNewPassword = RxBool(true); + + /// [AuthService] user for signing into an account. + final AuthService _authService; + + /// [MyUserService] used to delete authorized [MyUser]. + final MyUserService? _myUserService; + + /// Returns the authorization status. + Rx get authStatus => _authService.status; + + /// Returns the authenticated [MyUser]. + Rx? get myUser => _myUserService?.myUser; + + /// Signs in and redirects to the [Routes.erase] page. + /// + /// Username is [login]'s text and the password is [password]'s text. + Future signIn() async { + UserLogin? userLogin; + UserNum? num; + UserEmail? email; + UserPhone? phone; + + login.error.value = null; + password.error.value = null; + + if (login.text.isEmpty) { + password.error.value = 'err_incorrect_login_or_password'.l10n; + password.unsubmit(); + return; + } + + try { + userLogin = UserLogin(login.text.toLowerCase()); + } catch (e) { + // No-op. + } + + try { + num = UserNum(login.text); + } catch (e) { + // No-op. + } + + try { + email = UserEmail(login.text.toLowerCase()); + } catch (e) { + // No-op. + } + + try { + phone = UserPhone(login.text); + } catch (e) { + // No-op. + } + + if (password.text.isEmpty) { + password.error.value = 'err_incorrect_login_or_password'.l10n; + password.unsubmit(); + return; + } + + if (userLogin == null && num == null && email == null && phone == null) { + password.error.value = 'err_incorrect_login_or_password'.l10n; + password.unsubmit(); + return; + } + + try { + login.status.value = RxStatus.loading(); + password.status.value = RxStatus.loading(); + + await _authService.signIn( + UserPassword(password.text), + login: userLogin, + num: num, + email: email, + phone: phone, + ); + + router.go(Routes.erase); + router.tab = HomeTab.menu; + } on FormatException { + password.error.value = 'err_incorrect_login_or_password'.l10n; + } on ConnectionException { + password.unsubmit(); + password.error.value = 'err_data_transfer'.l10n; + } catch (e) { + password.unsubmit(); + password.error.value = 'err_data_transfer'.l10n; + rethrow; + } finally { + login.status.value = RxStatus.empty(); + password.status.value = RxStatus.empty(); + } + } + + /// Deletes [myUser]'s account. + Future deleteAccount() async { + try { + await _myUserService?.deleteMyUser(); + router.go(Routes.auth); + router.tab = HomeTab.chats; + } catch (_) { + MessagePopup.error('err_data_transfer'.l10n); + rethrow; + } + } +} diff --git a/lib/ui/page/erase/view.dart b/lib/ui/page/erase/view.dart new file mode 100644 index 00000000000..fca1c6a921f --- /dev/null +++ b/lib/ui/page/erase/view.dart @@ -0,0 +1,153 @@ +import 'package:animated_size_and_fade/animated_size_and_fade.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '/domain/service/my_user.dart'; +import '/l10n/l10n.dart'; +import '/themes.dart'; +import '/ui/page/home/page/chat/widget/back_button.dart'; +import '/ui/page/home/widget/app_bar.dart'; +import '/ui/page/home/widget/block.dart'; +import '/ui/page/home/widget/field_button.dart'; +import '/ui/page/home/widget/paddings.dart'; +import '/ui/page/login/widget/primary_button.dart'; +import '/ui/widget/modal_popup.dart'; +import '/ui/widget/svg/svg.dart'; +import '/ui/widget/text_field.dart'; +import '/util/get.dart'; +import '/util/message_popup.dart'; + +import 'controller.dart'; + +/// [Routes.erase] page. +class EraseView extends StatelessWidget { + const EraseView({super.key}); + + /// Displays a [EraseView] wrapped in a [ModalPopup]. + static Future show(BuildContext context) { + return ModalPopup.show(context: context, child: const EraseView()); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: EraseController(Get.find(), Get.findOrNull()), + builder: (EraseController c) { + return Scaffold( + appBar: CustomAppBar( + leading: const [StyledBackButton()], + title: Text('label_personal_data_deletion'.l10n), + actions: const [SizedBox(width: 32)], + ), + body: ListView( + children: [ + Block( + title: 'label_description'.l10n, + children: [ + Text('label_personal_data_deletion_description'.l10n), + ], + ), + _deletion(context, c), + ], + ), + ); + }, + ); + } + + /// Returns the [Block] containing the delete account button. + Widget _deletion(BuildContext context, EraseController c) { + final style = Theme.of(context).style; + + return Obx(() { + final List children; + + if (c.authStatus.value.isLoading) { + children = const [Center(child: CircularProgressIndicator())]; + } else if (c.authStatus.value.isEmpty) { + children = [ + Text('label_personal_data_deletion_authorize'.l10n), + const SizedBox(height: 25), + ReactiveTextField( + key: const Key('UsernameField'), + state: c.login, + label: 'label_sign_in_input'.l10n, + ), + const SizedBox(height: 16), + ReactiveTextField( + key: const ValueKey('PasswordField'), + state: c.password, + label: 'label_password'.l10n, + obscure: c.obscurePassword.value, + onSuffixPressed: c.obscurePassword.toggle, + treatErrorAsStatus: false, + trailing: SvgIcon( + c.obscurePassword.value + ? SvgIcons.visibleOff + : SvgIcons.visibleOn, + ), + ), + const SizedBox(height: 25), + Obx(() { + final bool enabled = !c.login.isEmpty.value && + c.login.error.value == null && + !c.password.isEmpty.value && + c.password.error.value == null; + + return PrimaryButton( + title: 'btn_proceed'.l10n, + onPressed: enabled ? c.signIn : null, + ); + }), + ]; + } else { + children = [ + Paddings.dense( + FieldButton( + key: const Key('DeleteAccount'), + text: 'btn_delete_account'.l10n, + onPressed: () => _deleteAccount(context, c), + danger: true, + style: style.fonts.normal.regular.danger, + ), + ), + ]; + } + + return AnimatedSizeAndFade( + fadeDuration: const Duration(milliseconds: 250), + sizeDuration: const Duration(milliseconds: 250), + child: Block( + key: Key( + '${c.authStatus.value.isLoading}${c.authStatus.value.isEmpty}', + ), + children: children, + ), + ); + }); + } + + /// Opens a confirmation popup deleting the [MyUser]'s account. + Future _deleteAccount(BuildContext context, EraseController c) async { + final style = Theme.of(context).style; + + final bool? result = await MessagePopup.alert( + 'label_delete_account'.l10n, + description: [ + TextSpan(text: 'alert_account_will_be_deleted1'.l10n), + TextSpan( + text: c.myUser?.value?.name?.val ?? + c.myUser?.value?.login?.val ?? + c.myUser?.value?.num.toString() ?? + 'dot'.l10n * 3, + style: style.fonts.normal.regular.onBackground, + ), + TextSpan(text: 'alert_account_will_be_deleted2'.l10n), + ], + ); + + if (result == true) { + await c.deleteAccount(); + } + } +} diff --git a/lib/ui/page/home/introduction/view.dart b/lib/ui/page/home/introduction/view.dart index 3bb1e237b73..7e95c3999dd 100644 --- a/lib/ui/page/home/introduction/view.dart +++ b/lib/ui/page/home/introduction/view.dart @@ -26,8 +26,10 @@ import '/domain/model/user.dart'; import '/l10n/l10n.dart'; import '/routes.dart'; import '/themes.dart'; +import '/ui/page/auth/widget/cupertino_button.dart'; import '/ui/page/home/widget/num.dart'; import '/ui/page/login/controller.dart'; +import '/ui/page/login/terms_of_use/view.dart'; import '/ui/page/login/view.dart'; import '/ui/widget/download_button.dart'; import '/ui/widget/modal_popup.dart'; @@ -148,7 +150,14 @@ class IntroductionView extends StatelessWidget { style: style.fonts.normal.regular.onPrimary, ), ), - const SizedBox(height: 16), + const SizedBox(height: 25 / 2), + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 8), ]; break; @@ -229,6 +238,14 @@ class IntroductionView extends StatelessWidget { ), ), ), + const SizedBox(height: 25 / 2), + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 8), ]; } else { header = ModalPopupHeader( @@ -244,7 +261,14 @@ class IntroductionView extends StatelessWidget { guestButton, const SizedBox(height: 15), signInButton, - const SizedBox(height: 16), + const SizedBox(height: 25 / 2), + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 8), ]; } break; diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index 3a55ec3780e..ab50ec64700 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -31,6 +31,8 @@ import '/domain/repository/settings.dart'; import '/l10n/l10n.dart'; import '/routes.dart'; import '/themes.dart'; +import '/ui/page/auth/widget/cupertino_button.dart'; +import '/ui/page/erase/view.dart'; import '/ui/page/home/page/chat/widget/back_button.dart'; import '/ui/page/home/page/my_profile/widget/switch_field.dart'; import '/ui/page/home/widget/app_bar.dart'; @@ -43,6 +45,8 @@ import '/ui/page/home/widget/field_button.dart'; import '/ui/page/home/widget/highlighted_container.dart'; import '/ui/page/home/widget/info_tile.dart'; import '/ui/page/home/widget/paddings.dart'; +import '/ui/page/login/privacy_policy/view.dart'; +import '/ui/page/login/terms_of_use/view.dart'; import '/ui/widget/animated_switcher.dart'; import '/ui/widget/download_button.dart'; import '/ui/widget/progress_indicator.dart'; @@ -314,11 +318,29 @@ class MyProfileView extends StatelessWidget { return block(children: [_danger(context, c)]); case ProfileTab.logout: - return const SafeArea( + return SafeArea( top: false, right: false, left: false, - child: SizedBox(), + child: Column( + children: [ + const SizedBox(height: 12), + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 6), + Center( + child: StyledCupertinoButton( + label: 'btn_privacy_policy'.l10n, + onPressed: () => PrivacyPolicy.show(context), + ), + ), + const SizedBox(height: 16), + ], + ), ); } }, @@ -1170,24 +1192,7 @@ Future _deletePhone( /// Opens a confirmation popup deleting the [MyUser]'s account. Future _deleteAccount(MyProfileController c, BuildContext context) async { - final style = Theme.of(context).style; - - final bool? result = await MessagePopup.alert( - 'label_delete_account'.l10n, - description: [ - TextSpan(text: 'alert_account_will_be_deleted1'.l10n), - TextSpan( - text: c.myUser.value?.name?.val ?? - c.myUser.value?.login?.val ?? - c.myUser.value?.num.toString() ?? - 'dot'.l10n * 3, - style: style.fonts.normal.regular.onBackground, - ), - TextSpan(text: 'alert_account_will_be_deleted2'.l10n), - ], + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const EraseView()), ); - - if (result == true) { - await c.deleteAccount(); - } } diff --git a/lib/ui/page/home/router.dart b/lib/ui/page/home/router.dart index 64fdf957b54..680a0439905 100644 --- a/lib/ui/page/home/router.dart +++ b/lib/ui/page/home/router.dart @@ -19,11 +19,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import '/domain/model/chat.dart'; import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; import '/domain/model/contact.dart'; import '/domain/model/user.dart'; import '/routes.dart'; +import '/ui/page/erase/view.dart'; import '/ui/page/work/page/vacancy/view.dart'; import '/ui/widget/custom_page.dart'; import 'page/chat/info/view.dart'; @@ -113,6 +114,12 @@ class HomeRouterDelegate extends RouterDelegate child: VacancyWorkView(work), )); } + } else if (route.startsWith(Routes.erase)) { + pages.add(const CustomPage( + key: ValueKey('ErasePage'), + name: Routes.erase, + child: EraseView(), + )); } } diff --git a/lib/ui/page/home/tab/work/view.dart b/lib/ui/page/home/tab/work/view.dart index f45d71ad855..5c457e6d549 100644 --- a/lib/ui/page/home/tab/work/view.dart +++ b/lib/ui/page/home/tab/work/view.dart @@ -24,7 +24,6 @@ import '/themes.dart'; import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/safe_scrollbar.dart'; import '/ui/page/work/widget/vacancy_button.dart'; -import '/ui/widget/animated_button.dart'; import 'controller.dart'; /// View of the [HomeTab.work] tab. @@ -74,19 +73,6 @@ class WorkTabView extends StatelessWidget { const SizedBox(width: 16), ], ), - actions: [ - AnimatedButton( - decorator: (child) => Container( - padding: const EdgeInsets.only(right: 18), - height: double.infinity, - child: Center(child: child), - ), - onPressed: () { - // No-op. - }, - child: Icon(Icons.more_vert, color: style.colors.primary), - ), - ], ), body: SafeScrollbar( controller: c.scrollController, diff --git a/lib/ui/page/login/privacy_policy/view.dart b/lib/ui/page/login/privacy_policy/view.dart new file mode 100644 index 00000000000..113574ddba1 --- /dev/null +++ b/lib/ui/page/login/privacy_policy/view.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '/ui/widget/markdown.dart'; +import '/ui/widget/modal_popup.dart'; + +/// Privacy policy page. +class PrivacyPolicy extends StatelessWidget { + const PrivacyPolicy({super.key}); + + /// Text of the privacy policy itself. + static const String _text = '''**Privacy Policy** + +This privacy policy applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. This service is intended for use "AS IS". + +**Information Collection and Use** + +The Application collects information when you download and use it. This information may include information such as + +* Your device's Internet Protocol address (e.g. IP address) +* The pages of the Application that you visit, the time and date of your visit, the time spent on those pages +* The time spent on the Application +* The operating system you use on your mobile device + +The Application does not gather precise information about the location of your mobile device. + +The Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways: + +* Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services. +* Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application. +* Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings. + +The Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions. + +For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information, including but not limited to Email, phone number. The information that the Service Provider request will be retained by them and used as described in this privacy policy. + +**Third Party Access** + +Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement. + +Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: + +* [Google Play Services](https://www.google.com/policies/privacy/) +* [Sentry](https://sentry.io/privacy/) + +The Service Provider may disclose User Provided and Automatically Collected Information: + +* as required by law, such as to comply with a subpoena, or similar legal process; +* when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the safety of others, investigate fraud, or respond to a government request; +* with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement. + +**Opt-Out Rights** + +You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network. + +**Data Retention Policy** + +The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at admin@gapopa.com and they will respond in a reasonable time. + +**Children** + +The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13. + +The Application does not address anyone under the age of 13\. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the necessary actions. + +**Security** + +The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains. + +**Changes** + +This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes. + +This privacy policy is effective as of 2024-04-11 + +**Your Consent** + +By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us. + +**Contact Us** + +If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at admin@gapopa.com.'''; + + /// Displays a [PrivacyPolicy] wrapped in a [ModalPopup]. + static Future show(BuildContext context) { + return ModalPopup.show(context: context, child: const PrivacyPolicy()); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const ModalPopupHeader(), + Expanded( + child: SingleChildScrollView( + padding: ModalPopup.padding(context), + child: const MarkdownWidget(_text), + ), + ), + ], + ); + } +} diff --git a/lib/ui/page/login/terms_of_use/view.dart b/lib/ui/page/login/terms_of_use/view.dart new file mode 100644 index 00000000000..b780f2a3587 --- /dev/null +++ b/lib/ui/page/login/terms_of_use/view.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '/ui/widget/markdown.dart'; +import '/ui/widget/modal_popup.dart'; + +/// Terms and conditions page. +class TermsOfUseView extends StatelessWidget { + const TermsOfUseView({super.key}); + + /// Text of the terms and conditions itself. + static const String _text = '''**Terms & Conditions** + +These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. + +Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is strongly advised that you thoroughly read and understand these terms prior to using the Application. + +The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As such, they reserve the right to modify the Application or charge for their services at any time and for any reason. The Service Provider assures you that any charges for the Application or its services will be clearly communicated to you. + +The Application stores and processes personal data that you have provided to the Service Provider in order to provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software restrictions and limitations imposed by the official operating system of your device. Such actions could expose your phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the Application not functioning correctly or at all. + +Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: + +* [Google Play Services](https://policies.google.com/terms) +* [Sentry](https://sentry.io/terms/) + +Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. + +If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the connection to the application, or other third-party charges. By using the application, you accept responsibility for any such charges, including roaming data charges if you use the application outside of your home territory (i.e., region or country) without disabling data roaming. If you are not the bill payer for the device on which you are using the application, they assume that you have obtained permission from the bill payer. + +Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery and you are unable to access the Service, the Service Provider cannot be held responsible. + +In terms of the Service Provider's responsibility for your use of the application, it is important to note that while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide information to them so that they can make it available to you. The Service Provider accepts no liability for any loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the application. + +The Service Provider may wish to update the application at some point. The application is currently available as per the requirements for the operating system (and for any additional systems they decide to extend the availability of the application to) may change, and you will need to download the updates if you want to continue using the application. The Service Provider does not guarantee that it will always update the application so that it is relevant to you and/or compatible with the particular operating system version installed on your device. However, you agree to always accept updates to the application when offered to you. The Service Provider may also wish to cease providing the application and may terminate its use at any time without providing termination notice to you. Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must cease using the application, and (if necessary) delete it from your device. + +**Changes to These Terms and Conditions** + +The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and Conditions on this page. + +These terms and conditions are effective as of 2024-04-11 + +**Contact Us** + +If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the Service Provider at admin@gapopa.com.'''; + + /// Displays a [TermsOfUseView] wrapped in a [ModalPopup]. + static Future show(BuildContext context) { + return ModalPopup.show(context: context, child: const TermsOfUseView()); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const ModalPopupHeader(), + Expanded( + child: SingleChildScrollView( + padding: ModalPopup.padding(context), + child: const MarkdownWidget(_text), + ), + ), + ], + ); + } +} diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index a04dfac6037..34ac77547a9 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -21,6 +21,7 @@ import 'package:get/get.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; +import '/ui/page/auth/widget/cupertino_button.dart'; import '/ui/page/home/page/chat/widget/chat_item.dart'; import '/ui/widget/modal_popup.dart'; import '/ui/widget/outlined_rounded_button.dart'; @@ -28,6 +29,7 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/ui/widget/widget_button.dart'; import 'controller.dart'; +import 'terms_of_use/view.dart'; import 'widget/primary_button.dart'; import 'widget/sign_button.dart'; @@ -303,7 +305,13 @@ class LoginView extends StatelessWidget { onPressed: () => c.stage.value = LoginViewStage.signUpWithEmail, ), - const SizedBox(height: 25 / 2), + const SizedBox(height: 16), + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + onPressed: () => TermsOfUseView.show(context), + ), + ), ]; break; diff --git a/lib/ui/page/work/page/freelance/widget/issue.dart b/lib/ui/page/work/page/freelance/widget/issue.dart index 86301d1e57d..f40e845191e 100644 --- a/lib/ui/page/work/page/freelance/widget/issue.dart +++ b/lib/ui/page/work/page/freelance/widget/issue.dart @@ -17,7 +17,6 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../controller.dart'; @@ -25,6 +24,7 @@ import '/themes.dart'; import '/ui/page/auth/widget/cupertino_button.dart'; import '/ui/page/home/tab/chats/widget/hovered_ink.dart'; import '/ui/page/home/widget/avatar.dart'; +import '/ui/widget/markdown.dart'; /// Visual representation of the provided [issue]. class IssueWidget extends StatelessWidget { @@ -115,31 +115,7 @@ class IssueWidget extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: Column( children: [ - MarkdownBody( - data: issue.description!, - onTapLink: (_, href, __) async => - await launchUrlString(href!), - styleSheet: MarkdownStyleSheet( - h2Padding: const EdgeInsets.fromLTRB(0, 24, 0, 4), - - // TODO: Exception. - h2: style.fonts.largest.bold.onBackground - .copyWith(fontSize: 20), - - p: style.fonts.normal.regular.onBackground, - code: style.fonts.small.regular.onBackground.copyWith( - letterSpacing: 1.2, - backgroundColor: style.colors.secondaryHighlight, - ), - codeblockDecoration: BoxDecoration( - color: style.colors.secondaryHighlight, - ), - codeblockPadding: const EdgeInsets.all(16), - blockquoteDecoration: BoxDecoration( - color: style.colors.secondaryHighlight, - ), - ), - ), + MarkdownWidget(issue.description!), const SizedBox(height: 16), StyledCupertinoButton( onPressed: onPressed, diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart new file mode 100644 index 00000000000..b69238853a7 --- /dev/null +++ b/lib/ui/widget/markdown.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '/themes.dart'; + +/// [MarkdownBody] stylized with the [Style]. +class MarkdownWidget extends StatelessWidget { + const MarkdownWidget(this.body, {super.key}); + + final String body; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).style; + + return MarkdownBody( + data: body, + onTapLink: (_, href, __) async => await launchUrlString(href!), + styleSheet: MarkdownStyleSheet( + h2Padding: const EdgeInsets.fromLTRB(0, 24, 0, 4), + + // TODO: Exception. + h2: style.fonts.largest.bold.onBackground.copyWith(fontSize: 20), + + p: style.fonts.normal.regular.onBackground, + code: style.fonts.small.regular.onBackground.copyWith( + letterSpacing: 1.2, + backgroundColor: style.colors.secondaryHighlight, + ), + codeblockDecoration: BoxDecoration( + color: style.colors.secondaryHighlight, + ), + codeblockPadding: const EdgeInsets.all(16), + blockquoteDecoration: BoxDecoration( + color: style.colors.secondaryHighlight, + ), + ), + ); + } +} diff --git a/lib/ui/widget/upgrade_popup/view.dart b/lib/ui/widget/upgrade_popup/view.dart index da1a7333152..4492c6e564f 100644 --- a/lib/ui/widget/upgrade_popup/view.dart +++ b/lib/ui/widget/upgrade_popup/view.dart @@ -17,14 +17,13 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; import '/ui/page/login/widget/primary_button.dart'; import '/ui/widget/download_button.dart'; +import '/ui/widget/markdown.dart'; import '/ui/widget/modal_popup.dart'; import '/ui/widget/outlined_rounded_button.dart'; import '/ui/worker/upgrade.dart'; @@ -105,32 +104,7 @@ class UpgradePopupView extends StatelessWidget { ), const SizedBox(height: 8), if (release.description != null) - MarkdownBody( - data: release.description!, - onTapLink: (_, href, __) async => - await launchUrlString(href!), - styleSheet: MarkdownStyleSheet( - h2Padding: const EdgeInsets.fromLTRB(0, 24, 0, 4), - - // TODO: Exception. - h2: style.fonts.largest.bold.onBackground - .copyWith(fontSize: 20), - - p: style.fonts.normal.regular.onBackground, - code: - style.fonts.small.regular.onBackground.copyWith( - letterSpacing: 1.2, - backgroundColor: style.colors.secondaryHighlight, - ), - codeblockDecoration: BoxDecoration( - color: style.colors.secondaryHighlight, - ), - codeblockPadding: const EdgeInsets.all(16), - blockquoteDecoration: BoxDecoration( - color: style.colors.secondaryHighlight, - ), - ), - ), + MarkdownWidget(release.description!), const SizedBox(height: 8), Text( release.publishedAt.toRelative(), diff --git a/lib/ui/worker/background/background.dart b/lib/ui/worker/background/background.dart deleted file mode 100644 index 0b28a2de421..00000000000 --- a/lib/ui/worker/background/background.dart +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:get/get.dart'; - -import '/domain/model/session.dart'; -import '/l10n/l10n.dart'; -import '/provider/hive/credentials.dart'; -import '/routes.dart'; -import '/util/platform_utils.dart'; -import 'src/main.dart'; - -/// Worker responsible for a [FlutterBackgroundService] management. -class BackgroundWorker extends GetxService { - BackgroundWorker(this._credentialsProvider); - - /// [CredentialsHiveProvider] listening [Credentials] changes. - final CredentialsHiveProvider _credentialsProvider; - - /// [FlutterBackgroundService] itself. - final FlutterBackgroundService _service = FlutterBackgroundService(); - - /// [StreamSubscription] to [CredentialsHiveProvider.boxEvents] sending new - /// [Credentials] to the [_service]. - StreamSubscription? _credentialsSubscription; - - /// [StreamSubscription]s to [FlutterBackgroundService.on] used to communicate - /// with the [_service]. - final List _onDataReceived = []; - - /// [Timer] sending "keep alive" messages to the [_service]. - Timer? _keepAliveTimer; - - /// Interval of the [_keepAliveTimer]. - static const _keepAliveInterval = Duration(seconds: 5); - - /// [Worker] reacting on the [RouterState.lifecycle] changes. - Worker? _lifecycleWorker; - - /// [Worker] reacting on the [L10n.chosen] changes. - Worker? _localizationWorker; - - @override - void onInit() { - if (PlatformUtils.isAndroid && !PlatformUtils.isWeb) { - _initService(); - - var lastCreds = _credentialsProvider.get(); - _credentialsSubscription = _credentialsProvider.boxEvents.listen((e) { - // If session is deleted, then ask the [_service] to stop. - if (e.deleted) { - lastCreds = null; - _service.invoke('stop'); - _dispose(); - } else { - var creds = e.value as Credentials?; - // Otherwise if [Credentials] mismatch is detected, update the - // [_service]. - if (creds?.access.secret != lastCreds?.access.secret || - creds?.refresh.secret != lastCreds?.refresh.secret) { - lastCreds = creds; - - if (creds == null) { - _service.invoke('stop'); - _dispose(); - } else { - // Start the service, if not already. Otherwise, send the new - // token to it. - if (_onDataReceived.isEmpty) { - _initService(); - } else { - _service.invoke('token', creds.toJson()); - } - } - } - } - }); - } - - super.onInit(); - } - - @override - void onClose() { - _dispose(); - _credentialsSubscription?.cancel(); - super.onClose(); - } - - /// Returns a [Stream] of the data from the background service. - Stream on(String method) { - if (PlatformUtils.isAndroid && !PlatformUtils.isWeb) { - return _service.on(method); - } - - return const Stream.empty(); - } - - /// Initializes the [_service]. - Future _initService() async { - WidgetsFlutterBinding.ensureInitialized(); - - // Do not initialize the service if no [Credentials] are stored. - if (_credentialsProvider.get() == null) { - return; - } - - for (var e in _onDataReceived) { - e.cancel(); - } - _onDataReceived.clear(); - - _onDataReceived.add(_service.on('requireToken').listen((e) { - var session = _credentialsProvider.get(); - FlutterBackgroundService().invoke('token', session!.toJson()); - })); - - _onDataReceived.add(_service.on('token').listen((e) { - _credentialsProvider.set(Credentials.fromJson(e!)); - })); - - await _service.configure( - androidConfiguration: AndroidConfiguration( - autoStart: true, - onStart: background, - isForegroundMode: true, - ), - iosConfiguration: IosConfiguration( - autoStart: true, - onForeground: background, - onBackground: onIosBackground, - ), - ); - - bool isRunning = await _service.isRunning(); - if (isRunning) { - _sendLifecycleUpdate(); - _lifecycleWorker = ever(router.lifecycle, (_) => _sendLifecycleUpdate()); - - _keepAliveTimer = Timer.periodic(_keepAliveInterval, (_) { - FlutterBackgroundService().isRunning().then((bool b) { - if (b) { - FlutterBackgroundService().invoke('ka'); - } else { - _keepAliveTimer?.cancel(); - } - }); - }); - - FlutterBackgroundService() - .invoke('l10n', {'locale': L10n.chosen.value.toString()}); - _localizationWorker = ever(L10n.chosen, (Language? locale) { - FlutterBackgroundService() - .invoke('l10n', {'locale': locale.toString()}); - }); - } - } - - /// Disposes the [_service] related resources. - void _dispose() { - _keepAliveTimer?.cancel(); - _keepAliveTimer = null; - _lifecycleWorker?.dispose(); - _lifecycleWorker = null; - _localizationWorker?.dispose(); - _localizationWorker = null; - - for (var e in _onDataReceived) { - e.cancel(); - } - _onDataReceived.clear(); - } - - /// Sends a [RouterState.lifecycle] value to the [_service]. - void _sendLifecycleUpdate() { - switch (router.lifecycle.value) { - case AppLifecycleState.resumed: - try { - FlutterBackgroundService().invoke('foreground'); - } catch (_) { - // No-op. - } - break; - - case AppLifecycleState.inactive: - case AppLifecycleState.paused: - try { - FlutterBackgroundService().invoke('background'); - } catch (_) { - // No-op. - } - break; - - case AppLifecycleState.detached: - try { - FlutterBackgroundService().invoke('detached'); - } catch (_) { - // No-op. - } - break; - - default: - break; - } - } -} diff --git a/lib/ui/worker/background/src/main.dart b/lib/ui/worker/background/src/main.dart deleted file mode 100644 index b9f4ef4bbda..00000000000 --- a/lib/ui/worker/background/src/main.dart +++ /dev/null @@ -1,453 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'dart:async'; -import 'dart:math'; - -import 'package:callkeep/callkeep.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:flutter_background_service_android/flutter_background_service_android.dart'; -import 'package:flutter_background_service_ios/flutter_background_service_ios.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:get/get.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:universal_io/io.dart'; -import 'package:path_provider_android/path_provider_android.dart'; - -import '/api/backend/extension/credentials.dart'; -import '/api/backend/extension/user.dart'; -import '/api/backend/schema.dart'; -import '/config.dart'; -import '/domain/model/chat.dart'; -import '/domain/model/precise_date_time/precise_date_time.dart'; -import '/domain/model/session.dart'; -import '/l10n/l10n.dart'; -import '/provider/gql/exceptions.dart'; -import '/provider/gql/graphql.dart'; -import '/provider/hive/credentials.dart'; -import '/routes.dart'; - -/// Background service iOS handler. -/// -/// Not really useful for our use-case. -FutureOr onIosBackground(ServiceInstance _) => true; - -/// Entry point of a background service. -Future background(ServiceInstance service) async { - WidgetsFlutterBinding.ensureInitialized(); - - if (Platform.isIOS) FlutterBackgroundServiceIOS.registerWith(); - if (Platform.isAndroid) FlutterBackgroundServiceAndroid.registerWith(); - - await _BackgroundService(service).init(); -} - -/// [FlutterBackgroundService] displaying incoming call notifications via -/// [FlutterCallkeep] on background. -class _BackgroundService { - _BackgroundService(this._service); - - /// [ServiceInstance] itself. - final ServiceInstance _service; - - /// [GraphQlProvider], used to communicate with backend. - final GraphQlProvider _provider = GraphQlProvider(); - - /// [FlutterCallkeep], used to display calls via native call APIs. - final FlutterCallkeep _callKeep = FlutterCallkeep(); - - /// [FlutterLocalNotificationsPlugin] displaying an incoming call notification - /// in case the [FlutterCallkeep] has no permissions to do so. - FlutterLocalNotificationsPlugin? _notificationPlugin; - - /// [Credentials] to use in the [_provider]. - Credentials? _credentials; - - /// [Timer], used to [_refreshSession] after some period of time. - Timer? _refreshSessionTimer; - - /// [StreamSubscription] to the [GraphQlProvider.incomingCallsTopEvents]. - StreamSubscription? _subscription; - - /// Indicator whether the main application is in foreground or not. - bool _isInForeground = true; - - /// Indicator whether the main application is considered alive or not. - bool _connectionEstablished = true; - - /// [Timer] setting the [_connectionEstablished] to `false` if no message - /// has been received for some time. - Timer? _connectionFailureTimer; - - /// [Duration] of the [_connectionFailureTimer] to consider the application as - /// non-active. - static const Duration _connectionFailureDuration = Duration(seconds: 6); - - /// [Duration] of the [_refreshSessionTimer] renewing the session. - /// - /// Should be enough to determine if [_connectionEstablished] is still `true`. - static const Duration _refreshSessionTimerDuration = Duration(seconds: 7); - - /// List of all the incoming calls. - final List _incomingCalls = []; - - /// List of calls being considered as accepted, so the [FlutterCallkeep] - /// doesn't send a decline request on them. - /// - /// [FlutterCallkeep] is used in the following way: - /// 1. When an incoming call is registered, the native call notification is - /// shown. - /// 2. If user declines the call, then the native call is being rejected and - /// the corresponding callback is fired, so a decline request is sent. - /// 3. If user accepts the call, then the native call is still being rejected - /// (as we don't want to use the native active call interface), so the - /// callback is still fired. That's where the [_acceptedCalls] are useful - /// as they contain the accepted call, so no decline request is sent. - final List _acceptedCalls = []; - - /// Indicator whether the [_refreshSession] request has been fulfilled or not. - /// - /// Used in the [_connectionFailureTimer] to prevent repeating the - /// [_refreshSession] on the main application connection loss. - bool _renewFulfilled = true; - - /// Initializes this [_BackgroundService]. - Future init() async { - if (_service is AndroidServiceInstance) { - _service.setAsForegroundService(); - } - - await Config.init(); - await _initCallKeep(); - - if (Platform.isAndroid) { - PathProviderAndroid.registerWith(); - } - - _provider.authExceptionHandler = (e) async { - _renewFulfilled = false; - return _refreshSession(); - }; - - _resetConnectionTimer(); - - await L10n.init(); - _initService(); - - // Start a [Timer] fetching the [_credentials] from the [Hive] in case - // [_connectionEstablished] is `false` after some time meaning the main - // application is non-active. - Timer(_refreshSessionTimerDuration, () async { - if (!_connectionEstablished && _credentials == null) { - await Hive.initFlutter('hive'); - var credentialsProvider = CredentialsHiveProvider(); - await credentialsProvider.init(); - - _credentials = credentialsProvider.get(); - _provider.token = _credentials?.access.secret; - _provider.reconnect(); - - if (_subscription == null) { - _subscribe(); - } - - await credentialsProvider.close(); - await Hive.close(); - } - }); - } - - /// Initializes the [FlutterCallkeep]. - Future _initCallKeep() async { - await _callKeep.setup(null, Config.callKeep, backgroundMode: true); - - _callKeep.on(CallKeepPerformAnswerCallAction(), - (CallKeepPerformAnswerCallAction event) async { - _acceptedCalls.add(event.callUUID!); - _incomingCalls.remove(event.callUUID!); - await _callKeep.rejectCall(event.callUUID!); - await _callKeep.backToForeground(); - Future.delayed(1.seconds, () { - _service.invoke('answer', {'callId': event.callUUID}); - - // To be sure the call is answered. - Future.delayed(1.seconds, () { - _service.invoke('answer', {'callId': event.callUUID}); - }); - }); - }); - - _callKeep.on(CallKeepPerformEndCallAction(), - (CallKeepPerformEndCallAction event) async { - _incomingCalls.remove(event.callUUID!); - if (!_acceptedCalls.contains(event.callUUID!)) { - await _provider.declineChatCall(ChatId(event.callUUID!)); - } else { - _acceptedCalls.remove(event.callUUID!); - } - }); - } - - /// Initializes the [_service]. - void _initService() { - _service.on('stop').listen((event) { - _resetConnectionTimer(); - _service.stopSelf(); - }); - - _service.on('foreground').listen((event) { - _resetConnectionTimer(); - _isInForeground = true; - _acceptedCalls.addAll(_incomingCalls); - _incomingCalls.clear(); - _callKeep.endAllCalls(); - }); - - _service.on('background').listen((event) { - _resetConnectionTimer(); - _isInForeground = false; - }); - - _service.on('detached').listen((event) { - _resetConnectionTimer(); - _isInForeground = false; - _connectionEstablished = false; - _connectionFailureTimer?.cancel(); - }); - - _service.on('token').listen((event) { - _resetConnectionTimer(); - - _renewFulfilled = true; - _credentials = Credentials.fromJson(event!); - - _refreshSessionTimer?.cancel(); - _provider.token = _credentials?.access.secret; - _provider.reconnect(); - - if (_subscription == null) { - _subscribe(); - } - }); - - _service.on('l10n').listen((event) { - _resetConnectionTimer(); - L10n.set(Language.fromTag(event!['locale'])); - }); - - _service.on('ka').listen((_) { - _resetConnectionTimer(); - }); - - _service.invoke('requireToken'); - - _setForegroundNotificationInfo( - title: 'label_service_initialized'.l10n, - content: '${DateTime.now()}', - ); - } - - /// Starts the [_refreshSessionTimer] renewing the [_credentials]. - Future _refreshSession() async { - _refreshSessionTimer?.cancel(); - _refreshSessionTimer = Timer( - _connectionEstablished ? _refreshSessionTimerDuration : Duration.zero, - () async { - if (!_connectionEstablished) { - if (_credentials?.refresh.expireAt - .isAfter(PreciseDateTime.now().toUtc()) == - true) { - try { - _renewFulfilled = true; - - var result = - await _provider.refreshSession(_credentials!.refresh.secret); - var ok = (result.refreshSession - as RefreshSession$Mutation$RefreshSession$CreateSessionOk); - _credentials = ok.toModel(); - - // Store the [Credentials] in the [Hive]. - Future(() async { - // Re-initialization is required every time since [Hive] may - // behave poorly between isolates. - await Hive.initFlutter('hive'); - var credentialsProvider = CredentialsHiveProvider(); - await credentialsProvider.init(); - await credentialsProvider.set(_credentials!); - await credentialsProvider.close(); - await Hive.close(); - }); - - _service.invoke('token', _credentials!.toJson()); - - _provider.token = _credentials?.access.secret; - _provider.reconnect(); - } on RefreshSessionException catch (_) { - _service.invoke('requireToken'); - } - } else { - _service.invoke('requireToken'); - } - } - }, - ); - } - - /// Resets the [_connectionFailureTimer] timer and sets the - /// [_connectionEstablished] to `true`. - void _resetConnectionTimer() { - _connectionEstablished = true; - _connectionFailureTimer?.cancel(); - _connectionFailureTimer = Timer(_connectionFailureDuration, () { - _connectionEstablished = false; - if (!_renewFulfilled) { - _refreshSession(); - } - }); - } - - /// Returns the lazily initialized [FlutterLocalNotificationsPlugin]. - Future _getNotificationPlugin() async { - if (_notificationPlugin == null) { - _notificationPlugin = FlutterLocalNotificationsPlugin(); - await _notificationPlugin!.initialize( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - } - - return _notificationPlugin!; - } - - /// Displays an incoming call notification for the provided [chatId] with the - /// provided [name]. - void _displayIncomingCall(ChatId chatId, String name) { - _callKeep.displayIncomingCall(chatId.val, name, handleType: 'generic'); - - // Show a notification if phone account permission is not granted. - Future(() => _callKeep.hasPhoneAccount()).then((b) { - if (!b) { - _getNotificationPlugin().then((v) { - v.show( - Random().nextInt(1 << 31), - 'label_incoming_call'.l10n, - name, - const NotificationDetails( - android: AndroidNotificationDetails('default', 'Default'), - ), - payload: '${Routes.chats}/$chatId', - ); - }); - } - }); - } - - /// Subscribes to the [GraphQlProvider.incomingCallsTopEvents]. - void _subscribe() { - _subscription = _provider.incomingCallsTopEvents(3).listen( - (event) { - var e = IncomingCallsTopEvents$Subscription.fromJson(event.data!) - .incomingChatCallsTopEvents; - switch (e.$$typename) { - case 'SubscriptionInitialized': - _setForegroundNotificationInfo( - title: 'label_service_connected'.l10n, - content: '${DateTime.now()}', - ); - break; - - case 'IncomingChatCallsTop': - if (!_isInForeground || !_connectionEstablished) { - _callKeep.endAllCalls(); - var calls = - (e as IncomingCallsTopEvents$Subscription$IncomingChatCallsTopEvents$IncomingChatCallsTop) - .list; - for (var call in calls) { - _incomingCalls.add(call.chatId.val); - - // TODO: Display `Chat` name instead of the `ChatCall.author`. - _displayIncomingCall(call.chatId, call.author.toModel().title); - } - - _setForegroundNotificationInfo( - title: 'label_service_connected'.l10n, - content: '${DateTime.now()}', - ); - } - break; - - case 'EventIncomingChatCallsTopChatCallAdded': - if (!_isInForeground || !_connectionEstablished) { - var call = - (e as IncomingCallsTopEvents$Subscription$IncomingChatCallsTopEvents$EventIncomingChatCallsTopChatCallAdded) - .call; - _incomingCalls.add(call.chatId.val); - - _setForegroundNotificationInfo( - title: 'label_service_connected'.l10n, - content: '${DateTime.now()}', - ); - - // TODO: Display `Chat` name instead of the `ChatCall.author`. - _displayIncomingCall(call.chatId, call.author.toModel().title); - } - break; - - case 'EventIncomingChatCallsTopChatCallRemoved': - var call = - (e as IncomingCallsTopEvents$Subscription$IncomingChatCallsTopEvents$EventIncomingChatCallsTopChatCallRemoved) - .call; - _incomingCalls.remove(call.chatId.val); - - _setForegroundNotificationInfo( - title: 'label_service_connected'.l10n, - content: '${DateTime.now()}', - ); - - _callKeep.endCall(call.chatId.val); - _callKeep.reportEndCallWithUUID(call.chatId.val, 0); - break; - } - }, - onError: (e) { - _setForegroundNotificationInfo( - title: 'label_service_reconnecting'.l10n, - content: '${DateTime.now()}', - ); - }, - ); - } - - /// Sets the foreground notification info to the provided [title] with - /// [content], if [_service] is [AndroidServiceInstance]. - Future _setForegroundNotificationInfo({ - required String title, - required String content, - }) { - if (_service is AndroidServiceInstance) { - return _service.setForegroundNotificationInfo( - title: title, - content: content, - ); - } - - return Future.value(); - } -} diff --git a/lib/ui/worker/call.dart b/lib/ui/worker/call.dart index 27012e1f281..e41dd43104b 100644 --- a/lib/ui/worker/call.dart +++ b/lib/ui/worker/call.dart @@ -18,15 +18,12 @@ import 'dart:async'; import 'dart:convert'; -import 'package:callkeep/callkeep.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:vibration/vibration.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -import '/config.dart'; import '/domain/model/chat.dart'; import '/domain/model/my_user.dart'; import '/domain/model/ongoing_call.dart'; @@ -38,7 +35,6 @@ import '/domain/service/my_user.dart'; import '/domain/service/notification.dart'; import '/l10n/l10n.dart'; import '/routes.dart'; -import '/util/android_utils.dart'; import '/util/audio_utils.dart'; import '/util/obs/obs.dart'; import '/util/platform_utils.dart'; @@ -76,9 +72,6 @@ class CallWorker extends DisposableService { /// Subscription to [WebUtils.onStorageChange] [stop]ping the [_audioPlayer]. StreamSubscription? _storageSubscription; - /// [FlutterCallkeep] used to require the call account permissions. - final FlutterCallkeep _callKeep = FlutterCallkeep(); - /// [ChatId]s of the calls that should be answered right away. final List _answeredCalls = []; @@ -131,7 +124,7 @@ class CallWorker extends DisposableService { if (PlatformUtils.isAndroid && !PlatformUtils.isWeb) { _lifecycleWorker = ever(router.lifecycle, (e) async { if (e.inForeground) { - _callKeep.endAllCalls(); + await FlutterCallkitIncoming.endAllCalls(); _callService.calls.forEach((id, call) { if (_answeredCalls.contains(id) && !call.value.isActive) { @@ -286,43 +279,6 @@ class CallWorker extends DisposableService { @override void onReady() { - if (PlatformUtils.isMobile && !PlatformUtils.isWeb) { - SchedulerBinding.instance.addPostFrameCallback((_) { - _callKeep.setup(router.context!, Config.callKeep); - - _callKeep.on(CallKeepPerformAnswerCallAction(), (event) { - if (event.callUUID != null) { - _answeredCalls.add(ChatId(event.callUUID!)); - } - }); - - if (PlatformUtils.isAndroid) { - AndroidUtils.canDrawOverlays().then((v) { - if (!v) { - showDialog( - barrierDismissible: false, - context: router.context!, - builder: (context) => AlertDialog( - title: Text('alert_popup_permissions_title'.l10n), - content: Text('alert_popup_permissions_description'.l10n), - actions: [ - TextButton( - onPressed: () { - AndroidUtils.openOverlaySettings().then((_) { - Navigator.of(context).pop(); - }); - }, - child: Text('alert_popup_permissions_button'.l10n), - ), - ], - ), - ); - } - }); - } - }); - } - _onFocusChanged = PlatformUtils.onFocusChanged.listen((f) => _focused = f); super.onReady(); diff --git a/lib/util/get.dart b/lib/util/get.dart new file mode 100644 index 00000000000..46fd10c754e --- /dev/null +++ b/lib/util/get.dart @@ -0,0 +1,14 @@ +import 'package:get/get.dart'; + +/// Extension adding ability to find non-strict dependencies from a +/// [GetInterface]. +extension GetExtension on GetInterface { + /// Returns the [S] dependency, if it [isRegistered]. + S? findOrNull({String? tag}) { + if (isRegistered(tag: tag)) { + return find(tag: tag); + } + + return null; + } +} diff --git a/pubspec.lock b/pubspec.lock index a70350f37c3..b080a3d47af 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,14 +177,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.1" - callkeep: - dependency: "direct main" - description: - name: callkeep - sha256: "9e86e9632a603a61f7045c179ea5ca0ee4da0a49fc5f80c2fe09fb422b96d3c6" - url: "https://pub.dev" - source: hosted - version: "0.3.3" characters: dependency: transitive description: @@ -512,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + flutter_callkit_incoming: + dependency: "direct main" + description: + name: flutter_callkit_incoming + sha256: "877363b53651457059f6d6adac2bcb659f05dd03741a816145b5778bd4eb6ac0" + url: "https://pub.dev" + source: hosted + version: "2.0.3" flutter_custom_cursor: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 22eda1092e9..8ea1b7344ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.10.0 audio_session: ^0.1.18 back_button_interceptor: ^7.0.0 - callkeep: ^0.3.3 collection: ^1.17.2 crypto: ^3.0.3 desktop_drop: ^0.4.1 @@ -37,6 +36,7 @@ dependencies: flutter_background_service_android: ^3.0.3 flutter_background_service_ios: ^2.4.0 flutter_custom_cursor: ^0.0.4 + flutter_callkit_incoming: ^2.0.3 flutter_list_view: git: https://github.com/krida2000/flutter_list_view.git flutter_local_notifications: ^16.1.0 diff --git a/test/widget/auth_test.dart b/test/widget/auth_test.dart index 3c48a93be13..4c79ccabfc8 100644 --- a/test/widget/auth_test.dart +++ b/test/widget/auth_test.dart @@ -52,7 +52,6 @@ import 'package:messenger/store/model/chat.dart'; import 'package:messenger/store/model/my_user.dart'; import 'package:messenger/themes.dart'; import 'package:messenger/ui/page/auth/view.dart'; -import 'package:messenger/ui/worker/background/background.dart'; import 'package:messenger/util/audio_utils.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -130,7 +129,6 @@ void main() async { Get.put(settingsProvider); Get.put(callCredentialsProvider); Get.put(NotificationService(graphQlProvider)); - Get.put(BackgroundWorker(credentialsProvider)); Get.put(monologProvider); AuthService authService = Get.put( diff --git a/web/privacy.html b/web/privacy.html new file mode 100644 index 00000000000..95a8dff0f3d --- /dev/null +++ b/web/privacy.html @@ -0,0 +1,124 @@ + + + + + + + + + Privacy Policy + + + + + Privacy Policy +

This privacy policy applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was + created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. This + service is intended for use "AS IS".


Information Collection and Use +

The Application collects information when you download and use it. This information may include information such as +

+
    +
  • Your device's Internet Protocol address (e.g. IP address)
  • +
  • The pages of the Application that you visit, the time and date of your visit, the time spent on those pages
  • +
  • The time spent on the Application
  • +
  • The operating system you use on your mobile device
  • +
+


+

The Application does not gather precise information about the location of your mobile device.

+
+

The Application collects your device's location, which helps the Service Provider determine your approximate + geographical location and make use of in below ways:

+
    +
  • Geolocation Services: The Service Provider utilizes location data to provide features such as personalized + content, relevant recommendations, and location-based services.
  • +
  • Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user + behavior, identify trends, and improve the overall performance and functionality of the Application.
  • +
  • Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external + services. These services assist them in enhancing the Application and optimizing their offerings.
  • +
+

+

The Service Provider may use the information you provided to contact you from time to time to provide you with + important information, required notices and marketing promotions.


+

For a better experience, while using the Application, the Service Provider may require you to provide us with + certain personally identifiable information, including but not limited to Email, phone number. The information that + the Service Provider request will be retained by them and used as described in this privacy policy.

+
Third Party Access +

Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in + improving the Application and their service. The Service Provider may share your information with third parties in + the ways that are described in this privacy statement.

+

+

Please note that the Application utilizes third-party services that have their own Privacy Policy about handling + data. Below are the links to the Privacy Policy of the third-party service providers used by the Application:

+ +

+

The Service Provider may disclose User Provided and Automatically Collected Information:

+
    +
  • as required by law, such as to comply with a subpoena, or similar legal process;
  • +
  • when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the + safety of others, investigate fraud, or respond to a government request;
  • +
  • with their trusted services providers who work on their behalf, do not have an independent use of the + information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement.
  • +
+


Opt-Out Rights +

You can stop all collection of information by the Application easily by uninstalling it. You may use the standard + uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or + network.


Data Retention Policy +

The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable + time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please + contact them at admin@gapopa.com and they will respond in a reasonable time.

+
Children +

The Service Provider does not use the Application to knowingly solicit data from or market to children under the + age of 13.

+

+

The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect + personally identifiable information from children under 13 years of age. In the case the Service Provider discover + that a child under 13 has provided personal information, the Service Provider will immediately delete this from + their servers. If you are a parent or guardian and you are aware that your child has provided us with personal + information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the + necessary actions.

+

Security +

The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider + provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and + maintains.


Changes +

This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any + changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this + Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.


+

This privacy policy is effective as of 2024-04-11


Your Consent +

By using the Application, you are consenting to the processing of your information as set forth in this Privacy + Policy now and as amended by us.


Contact Us +

If you have any questions regarding privacy while using the Application, or have questions about the practices, + please contact the Service Provider via email at admin@gapopa.com.

+
+

This privacy policy page was generated by App Privacy Policy Generator

+ + + \ No newline at end of file diff --git a/web/terms.html b/web/terms.html new file mode 100644 index 00000000000..9ad12b369bb --- /dev/null +++ b/web/terms.html @@ -0,0 +1,101 @@ + + + + + + + + + Terms & Conditions + + + + + Terms & Conditions
+

These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices that + was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. +


+

Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is + strongly advised that you thoroughly read and understand these terms prior to using the Application.


+

The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As + such, they reserve the right to modify the Application or charge for their services at any time and for any reason. + The Service Provider assures you that any charges for the Application or its services will be clearly communicated + to you.


+

The Application stores and processes personal data that you have provided to the Service Provider in order to + provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. + The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software + restrictions and limitations imposed by the official operating system of your device. Such actions could expose your + phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the + Application not functioning correctly or at all.

+
+

Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below + are the links to the Terms and Conditions of the third-party service providers used by the Application:

+ +

+

Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the + Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. + The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of + access to Wi-Fi or if you have exhausted your data allowance.


+

If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's + agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the + connection to the application, or other third-party charges. By using the application, you accept responsibility for + any such charges, including roaming data charges if you use the application outside of your home territory (i.e., + region or country) without disabling data roaming. If you are not the bill payer for the device on which you are + using the application, they assume that you have obtained permission from the bill payer.


+

Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For + instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery + and you are unable to access the Service, the Service Provider cannot be held responsible.


+

In terms of the Service Provider's responsibility for your use of the application, it is important to note that + while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide + information to them so that they can make it available to you. The Service Provider accepts no liability for any + loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the + application.


+

The Service Provider may wish to update the application at some point. The application is currently available as + per the requirements for the operating system (and for any additional systems they decide to extend the availability + of the application to) may change, and you will need to download the updates if you want to continue using the + application. The Service Provider does not guarantee that it will always update the application so that it is + relevant to you and/or compatible with the particular operating system version installed on your device. However, + you agree to always accept updates to the application when offered to you. The Service Provider may also wish to + cease providing the application and may terminate its use at any time without providing termination notice to you. + Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms + will end; (b) you must cease using the application, and (if necessary) delete it from your device.

+
Changes to These Terms and Conditions +

The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this + page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and + Conditions on this page.


+

These terms and conditions are effective as of 2024-04-11


Contact Us +

If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the + Service Provider at admin@gapopa.com.

+
+

This Terms and Conditions page was generated by App Privacy Policy Generator

+ + + \ No newline at end of file From dc4e89ecde3e9cc6bd640e53be8adec215e6dcfd Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 09:12:17 +0300 Subject: [PATCH 02/88] Fix lints --- lib/config.dart | 22 ---------------------- lib/main.dart | 2 +- lib/ui/page/erase/controller.dart | 20 +++++++++++++++++--- lib/ui/page/erase/view.dart | 17 +++++++++++++++++ lib/ui/page/login/privacy_policy/view.dart | 21 +++++++++++++++++++-- lib/ui/page/login/terms_of_use/view.dart | 17 +++++++++++++++++ lib/ui/widget/markdown.dart | 17 +++++++++++++++++ lib/util/get.dart | 19 ++++++++++++++++++- web/privacy.html | 2 +- 9 files changed, 107 insertions(+), 30 deletions(-) diff --git a/lib/config.dart b/lib/config.dart index 232e2c2a926..3eac0406239 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -22,7 +22,6 @@ import 'package:flutter/widgets.dart'; import 'package:log_me/log_me.dart' as me; import 'package:toml/toml.dart'; -import '/l10n/l10n.dart'; import '/util/log.dart'; import '/util/platform_utils.dart'; import 'pubspec.g.dart'; @@ -104,27 +103,6 @@ class Config { /// Optional copyright to display at the bottom of [Routes.auth] page. static String copyright = ''; - /// Returns a [Map] being a configuration passed to a [FlutterCallkeep] - /// instance to initialize it. - static Map get callKeep { - return { - 'ios': {'appName': 'Gapopa'}, - 'android': { - 'alertTitle': 'label_call_permissions_title'.l10n, - 'alertDescription': 'label_call_permissions_description'.l10n, - 'cancelButton': 'btn_dismiss'.l10n, - 'okButton': 'btn_allow'.l10n, - 'foregroundService': { - 'channelId': 'default', - 'channelName': 'Default', - 'notificationTitle': 'My app is running on background', - 'notificationIcon': 'mipmap/ic_notification_launcher', - }, - 'additionalPermissions': [], - }, - }; - } - /// Initializes this [Config] by applying values from the following sources /// (in the following order): /// - compile-time environment variables; diff --git a/lib/main.dart b/lib/main.dart index 1e12f4e2cce..8f1127394b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -232,7 +232,7 @@ Future main() async { .then((_) => ready.finish()); } -/// Initializes the [FlutterCallkeep] and displays an incoming call +/// Initializes the [FlutterCallkitIncoming] and displays an incoming call /// notification, if the provided [message] is about a call. /// /// Must be a top level function, as intended to be used as a Firebase Cloud diff --git a/lib/ui/page/erase/controller.dart b/lib/ui/page/erase/controller.dart index 2c048047e70..080986d0ced 100644 --- a/lib/ui/page/erase/controller.dart +++ b/lib/ui/page/erase/controller.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:get/get.dart'; import '/domain/model/my_user.dart'; @@ -30,9 +47,6 @@ class EraseController extends GetxController { /// Indicator whether the [password] should be obscured. final RxBool obscurePassword = RxBool(true); - /// Indicator whether the [newPassword] should be obscured. - final RxBool obscureNewPassword = RxBool(true); - /// [AuthService] user for signing into an account. final AuthService _authService; diff --git a/lib/ui/page/erase/view.dart b/lib/ui/page/erase/view.dart index fca1c6a921f..8cee65a9278 100644 --- a/lib/ui/page/erase/view.dart +++ b/lib/ui/page/erase/view.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; diff --git a/lib/ui/page/login/privacy_policy/view.dart b/lib/ui/page/login/privacy_policy/view.dart index 113574ddba1..4368e773bb5 100644 --- a/lib/ui/page/login/privacy_policy/view.dart +++ b/lib/ui/page/login/privacy_policy/view.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:flutter/material.dart'; import '/ui/widget/markdown.dart'; @@ -54,13 +71,13 @@ You can stop all collection of information by the Application easily by uninstal **Data Retention Policy** -The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at admin@gapopa.com and they will respond in a reasonable time. +The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please proceed to the https://gapopa.com/erase for instructions on how to do so. **Children** The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13. -The Application does not address anyone under the age of 13\. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the necessary actions. +The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the necessary actions. **Security** diff --git a/lib/ui/page/login/terms_of_use/view.dart b/lib/ui/page/login/terms_of_use/view.dart index b780f2a3587..856dadc9d03 100644 --- a/lib/ui/page/login/terms_of_use/view.dart +++ b/lib/ui/page/login/terms_of_use/view.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:flutter/material.dart'; import '/ui/widget/markdown.dart'; diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index b69238853a7..c5f4b3cff19 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/util/get.dart b/lib/util/get.dart index 46fd10c754e..02b612f5aba 100644 --- a/lib/util/get.dart +++ b/lib/util/get.dart @@ -1,9 +1,26 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:get/get.dart'; /// Extension adding ability to find non-strict dependencies from a /// [GetInterface]. extension GetExtension on GetInterface { - /// Returns the [S] dependency, if it [isRegistered]. + /// Returns the [S] dependency, if it [Inst.isRegistered]. S? findOrNull({String? tag}) { if (isRegistered(tag: tag)) { return find(tag: tag); diff --git a/web/privacy.html b/web/privacy.html index 95a8dff0f3d..84a948637b8 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -93,7 +93,7 @@ network.


Data Retention Policy

The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please - contact them at admin@gapopa.com and they will respond in a reasonable time.

+ proceed to the https://gapopa.com/erase for instructions on how to do so.


Children

The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13.

From 6d8bedebea13a39bbc6565b6440d27e4dbab2152 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 09:31:45 +0300 Subject: [PATCH 03/88] Fix E2E test --- lib/ui/page/erase/view.dart | 1 + test/e2e/features/auth/create_account/.feature | 2 ++ test/e2e/parameters/keys.dart | 1 + 3 files changed, 4 insertions(+) diff --git a/lib/ui/page/erase/view.dart b/lib/ui/page/erase/view.dart index 8cee65a9278..cbd83b12345 100644 --- a/lib/ui/page/erase/view.dart +++ b/lib/ui/page/erase/view.dart @@ -48,6 +48,7 @@ class EraseView extends StatelessWidget { @override Widget build(BuildContext context) { return GetBuilder( + key: const Key('EraseView'), init: EraseController(Get.find(), Get.findOrNull()), builder: (EraseController c) { return Scaffold( diff --git a/test/e2e/features/auth/create_account/.feature b/test/e2e/features/auth/create_account/.feature index ca77cd2bdfb..a50d83218ad 100644 --- a/test/e2e/features/auth/create_account/.feature +++ b/test/e2e/features/auth/create_account/.feature @@ -40,6 +40,8 @@ Feature: Account creation And I tap `DangerZone` button And I scroll `MyProfileScrollable` until `DeleteAccount` is present And I tap `DeleteAccount` button + And I wait until `EraseView` is present + And I tap `DeleteAccount` button And I tap `Proceed` button Then I wait until `AuthView` is present And I pause for 1 second diff --git a/test/e2e/parameters/keys.dart b/test/e2e/parameters/keys.dart index 117a0ac96b3..0ae31ec6cab 100644 --- a/test/e2e/parameters/keys.dart +++ b/test/e2e/parameters/keys.dart @@ -82,6 +82,7 @@ enum WidgetKey { Email, EmailsExpandable, ExpandSigning, + EraseView, FavoriteChatButton, FavoriteContactButton, ForwardButton, From 6ae9066ee358e40a5715d1423865c6c0a84d8323 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 10:30:02 +0300 Subject: [PATCH 04/88] Fix E2E test --- lib/ui/page/erase/view.dart | 3 ++- test/e2e/features/auth/create_account/.feature | 6 ++++-- test/e2e/parameters/keys.dart | 8 +++++--- test/e2e/steps/scroll_until.dart | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/ui/page/erase/view.dart b/lib/ui/page/erase/view.dart index cbd83b12345..4faf20fe1e4 100644 --- a/lib/ui/page/erase/view.dart +++ b/lib/ui/page/erase/view.dart @@ -58,6 +58,7 @@ class EraseView extends StatelessWidget { actions: const [SizedBox(width: 32)], ), body: ListView( + key: const Key('EraseScrollable'), children: [ Block( title: 'label_description'.l10n, @@ -122,7 +123,7 @@ class EraseView extends StatelessWidget { children = [ Paddings.dense( FieldButton( - key: const Key('DeleteAccount'), + key: const Key('ConfirmDelete'), text: 'btn_delete_account'.l10n, onPressed: () => _deleteAccount(context, c), danger: true, diff --git a/test/e2e/features/auth/create_account/.feature b/test/e2e/features/auth/create_account/.feature index a50d83218ad..18fe9068dd7 100644 --- a/test/e2e/features/auth/create_account/.feature +++ b/test/e2e/features/auth/create_account/.feature @@ -40,8 +40,10 @@ Feature: Account creation And I tap `DangerZone` button And I scroll `MyProfileScrollable` until `DeleteAccount` is present And I tap `DeleteAccount` button - And I wait until `EraseView` is present - And I tap `DeleteAccount` button + Then I wait until `EraseView` is present + + When I scroll `EraseScrollable` until `ConfirmDelete` is present + And I tap `ConfirmDelete` button And I tap `Proceed` button Then I wait until `AuthView` is present And I pause for 1 second diff --git a/test/e2e/parameters/keys.dart b/test/e2e/parameters/keys.dart index 0ae31ec6cab..f8f5255b437 100644 --- a/test/e2e/parameters/keys.dart +++ b/test/e2e/parameters/keys.dart @@ -54,12 +54,13 @@ enum WidgetKey { ChatsTab, ClearHistoryButton, CloseButton, - ConfirmLogoutButton, - ConfirmLogoutView, ConfirmationCode, ConfirmationPhone, + ConfirmDelete, ConfirmedEmail, ConfirmedPhone, + ConfirmLogoutButton, + ConfirmLogoutView, Contacts, ContactsButton, ContactsLoading, @@ -81,8 +82,9 @@ enum WidgetKey { EditProfileButton, Email, EmailsExpandable, - ExpandSigning, + EraseScrollable, EraseView, + ExpandSigning, FavoriteChatButton, FavoriteContactButton, ForwardButton, diff --git a/test/e2e/steps/scroll_until.dart b/test/e2e/steps/scroll_until.dart index 6b4a5e74e03..a4e087ab649 100644 --- a/test/e2e/steps/scroll_until.dart +++ b/test/e2e/steps/scroll_until.dart @@ -136,7 +136,7 @@ extension ScrollAppDriverAdapter final ScrollPosition position = state.position; if (await isPresent(finder)) { - await Scrollable.ensureVisible(finder.evaluate().single); + await Scrollable.ensureVisible(finder.evaluate().first); // If [finder] is present and it's within our view, then break the loop. if (tester.getCenter(finder.first).dy <= height - dy || From 5e6bdcce0d929ae304a8ff69d8455d16b76c05ac Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 11:00:20 +0300 Subject: [PATCH 05/88] Fix `Firebase.initializeApp` --- lib/domain/service/notification.dart | 12 ++++++++++-- lib/main.dart | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/domain/service/notification.dart b/lib/domain/service/notification.dart index d5043d871cc..78476209a0f 100644 --- a/lib/domain/service/notification.dart +++ b/lib/domain/service/notification.dart @@ -394,14 +394,22 @@ class NotificationService extends DisposableService { '$runtimeType', ); + try { + await Firebase.initializeApp(options: options); + } catch (e) { + if (e.toString().contains('[core/duplicate-app]')) { + // No-op. + } else { + rethrow; + } + } + FirebaseMessaging.onMessageOpenedApp.listen(onResponse); if (onBackground != null) { FirebaseMessaging.onBackgroundMessage(onBackground); } - await Firebase.initializeApp(options: options); - final RemoteMessage? initial = await FirebaseMessaging.instance.getInitialMessage(); diff --git a/lib/main.dart b/lib/main.dart index 8f1127394b4..519c68ba085 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,6 +50,7 @@ import 'domain/model/chat.dart'; import 'domain/model/session.dart'; import 'domain/repository/auth.dart'; import 'domain/service/auth.dart'; +import 'firebase_options.dart'; import 'l10n/l10n.dart'; import 'provider/gql/exceptions.dart'; import 'provider/gql/graphql.dart'; @@ -239,7 +240,19 @@ Future main() async { /// Messaging notification background handler. @pragma('vm:entry-point') Future handlePushNotification(RemoteMessage message) async { - await Firebase.initializeApp(); + try { + await Firebase.initializeApp( + options: PlatformUtils.pushNotifications + ? DefaultFirebaseOptions.currentPlatform + : null, + ); + } catch (e) { + if (e.toString().contains('[core/duplicate-app]')) { + // No-op. + } else { + rethrow; + } + } Log.debug('handlePushNotification($message)', 'main'); From 39cc00033f400a3cbac85bba31d847883ca77037 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 11:51:38 +0300 Subject: [PATCH 06/88] Corrections --- android/app/src/main/res/raw/ringtone.mp3 | Bin 0 -> 129566 bytes lib/main.dart | 9 ++++++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/res/raw/ringtone.mp3 diff --git a/android/app/src/main/res/raw/ringtone.mp3 b/android/app/src/main/res/raw/ringtone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..eade2ceb0a1737c1f3b230de4e23ababcef94092 GIT binary patch literal 129566 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

-Imrm7ONRoVAm)IABA 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

9l;0->wtpc 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 Ca6a?^ literal 0 HcmV?d00001 diff --git a/lib/main.dart b/lib/main.dart index 519c68ba085..dca7ffed31b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -290,13 +290,16 @@ Future handlePushNotification(RemoteMessage message) async { } }); + // TODO: Use stored in [ApplicationSettings] language here. + await L10n.init(); + await FlutterCallkitIncoming.showCallkitIncoming( CallKitParams( id: message.data['chatId'], nameCaller: message.notification?.title ?? 'gapopa', appName: 'Gapopa', avatar: '', // TODO: Add avatar to FCM notifications. - handle: '0123456789', + handle: message.data['chatId'], type: 0, textAccept: 'btn_accept'.l10n, textDecline: 'btn_decline'.l10n, @@ -306,14 +309,14 @@ Future handlePushNotification(RemoteMessage message) async { android: AndroidParams( isCustomNotification: true, isShowLogo: false, - ringtonePath: 'system_ringtone_default', + ringtonePath: 'ringtone', backgroundColor: '#0955fa', backgroundUrl: '', // TODO: Add avatar to FCM notifications. actionColor: '#4CAF50', textColor: '#ffffff', incomingCallNotificationChannelName: 'label_incoming_call'.l10n, missedCallNotificationChannelName: 'label_chat_call_missed'.l10n, - isShowCallID: false, + isShowCallID: true, isShowFullLockedScreen: true, ), ), From f63c4f45984c6858d65ef021742eaefdea95ef14 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 12:30:12 +0300 Subject: [PATCH 07/88] Corrections --- CHANGELOG.md | 4 ++-- lib/ui/page/erase/view.dart | 1 - lib/ui/widget/markdown.dart | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a28750c622..546f6e21ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ All user visible changes to this project will be documented in this file. This p - UI: - Account deletion page. ([#944]) - - Terms and conditions. ([#944]) - - Privacy policy. ([#944]) + - Terms and conditions page. ([#944]) + - Privacy policy page. ([#944]) [#944]: /../../pull/944 diff --git a/lib/ui/page/erase/view.dart b/lib/ui/page/erase/view.dart index 4faf20fe1e4..c0036a8dce7 100644 --- a/lib/ui/page/erase/view.dart +++ b/lib/ui/page/erase/view.dart @@ -33,7 +33,6 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/util/get.dart'; import '/util/message_popup.dart'; - import 'controller.dart'; /// [Routes.erase] page. diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index c5f4b3cff19..307f67dc5b6 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -25,6 +25,7 @@ import '/themes.dart'; class MarkdownWidget extends StatelessWidget { const MarkdownWidget(this.body, {super.key}); + /// Text to parse and render as a markdown. final String body; @override From 658822a374f06bd95bb56d2825f371bfdc1a1d48 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 12:51:00 +0300 Subject: [PATCH 08/88] Bootstrap reporting modals --- assets/l10n/en-US.ftl | 1 + assets/l10n/ru-RU.ftl | 5 +++ .../page/home/page/chat/info/controller.dart | 8 ++++ lib/ui/page/home/page/chat/info/view.dart | 42 +++++++++++++++++-- lib/ui/page/home/page/user/controller.dart | 8 ++++ lib/ui/page/home/page/user/view.dart | 42 +++++++++++++++++-- lib/util/message_popup.dart | 28 ++++++++----- 7 files changed, 116 insertions(+), 18 deletions(-) diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index bfe2a064c21..c6bcaa95bf5 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -926,6 +926,7 @@ label_replies = [{$count} {$count -> [1] reply *[other] replies }] +label_report = Report label_required = Required label_requirements = Requirements label_requirements_backend_developer = diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index d609213a91f..90a9b600c49 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -32,6 +32,8 @@ alert_chat_will_be_deleted1 = Чат{" "} alert_chat_will_be_deleted2 = {" "}будет удалён. Чтобы восстановить чат, пожалуйста, воспользуйтесь поиском. +alert_chat_will_be_reported1 = На чат{" "} +alert_chat_will_be_reported2 = {" "}будет отправлена жалоба. alert_chats_will_be_deleted = Чаты ({$count}) будут удалены. Продолжить? alert_contact_will_be_removed1 = Контакт{" "} alert_contact_will_be_removed2 = {" "}будет удалён. @@ -60,6 +62,8 @@ alert_user_will_be_blocked1 = Пользователь{" "} alert_user_will_be_blocked2 = {" "}будет заблокирован. alert_user_will_be_removed1 = Пользователь{" "} alert_user_will_be_removed2 = {" "}будет удалён из группы. +alert_user_will_be_reported1 = На пользователя{" "} +alert_user_will_be_reported2 = {" "}будет отправлена жалоба. alert_you_will_leave_group = Вы покинете группу. btn_accept = Принять btn_add = Добавить @@ -954,6 +958,7 @@ label_replies = [{$count} {$count -> [few] ответа *[other] ответов }] +label_report = Пожаловаться label_required = Обязательно label_requirements = Требуется label_requirements_backend_developer = diff --git a/lib/ui/page/home/page/chat/info/controller.dart b/lib/ui/page/home/page/chat/info/controller.dart index 1ab34303b41..29cc442b074 100644 --- a/lib/ui/page/home/page/chat/info/controller.dart +++ b/lib/ui/page/home/page/chat/info/controller.dart @@ -102,6 +102,9 @@ class ChatInfoController extends GetxController { /// Indicator whether [AppBar] should display the [ChatName] and [ChatAvatar]. final RxBool displayName = RxBool(false); + /// [TextFieldState] for report reason. + final TextFieldState reporting = TextFieldState(); + /// [Chat]s service used to get the [chat] value. final ChatService _chatService; @@ -322,6 +325,11 @@ class ChatInfoController extends GetxController { } } + /// Reports the [chat]. + Future reportChat() async { + // TODO: Implement. + } + /// Clears all the [ChatItem]s of the [chat]. Future clearChat() async { try { diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index a9bf7644bbc..cca70e6905c 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -37,6 +37,7 @@ import '/ui/page/home/widget/big_avatar.dart'; import '/ui/page/home/widget/block.dart'; import '/ui/page/home/widget/direct_link.dart'; import '/ui/page/home/widget/quick_button.dart'; +import '/ui/page/login/widget/primary_button.dart'; import '/ui/widget/animated_button.dart'; import '/ui/widget/animated_switcher.dart'; import '/ui/widget/context_menu/menu.dart'; @@ -382,7 +383,7 @@ class ChatInfoView extends StatelessWidget { const SizedBox(height: 8), if (!c.isMonolog) ActionButton( - onPressed: () {}, + onPressed: () => _reportChat(c, context), text: 'btn_report'.l10n, trailing: Transform.translate( offset: const Offset(0, -1), @@ -472,9 +473,7 @@ class ChatInfoView extends StatelessWidget { ), if (!c.isMonolog) ContextMenuButton( - onPressed: () { - // TODO: Implement. - }, + onPressed: () => _reportChat(c, context), label: 'btn_report'.l10n, trailing: const SvgIcon(SvgIcons.report), inverted: const SvgIcon(SvgIcons.reportWhite), @@ -689,4 +688,39 @@ class ChatInfoView extends StatelessWidget { await c.clearChat(); } } + + /// Opens a confirmation popup reporting this [Chat]. + Future _reportChat(ChatInfoController c, BuildContext context) async { + final style = Theme.of(context).style; + + final bool? result = await MessagePopup.alert( + 'label_delete_chat'.l10n, + description: [ + TextSpan(text: 'alert_chat_will_be_reported1'.l10n), + TextSpan( + text: c.chat?.title, + style: style.fonts.normal.regular.onBackground, + ), + TextSpan(text: 'alert_chat_will_be_reported2'.l10n), + ], + additional: [ + const SizedBox(height: 25), + ReactiveTextField(state: c.reporting, label: 'label_reason'.l10n), + ], + button: (context) { + return Obx(() { + return PrimaryButton( + title: 'btn_proceed'.l10n, + onPressed: c.reporting.isEmpty.value + ? null + : () => Navigator.of(context).pop(true), + ); + }); + }, + ); + + if (result == true) { + await c.reportChat(); + } + } } diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index 57292868dc2..047174eecae 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -96,6 +96,9 @@ class UserController extends GetxController { /// [TextFieldState] for blocking reason. final TextFieldState reason = TextFieldState(); + /// [TextFieldState] for report reason. + final TextFieldState reporting = TextFieldState(); + /// [TextFieldState] for [ChatContact] name editing. late final TextFieldState name; @@ -303,6 +306,11 @@ class UserController extends GetxController { } } + /// Reports the [user]. + Future report() async { + // TODO: Implement. + } + /// Removes the [user] from the blocklist of the authenticated [MyUser]. Future unblock() async { blocklistStatus.value = RxStatus.loading(); diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 5f3be3cc19b..9051195eac7 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -36,6 +36,7 @@ import '/ui/page/home/widget/copy_or_share.dart'; import '/ui/page/home/widget/info_tile.dart'; import '/ui/page/home/widget/paddings.dart'; import '/ui/page/home/widget/quick_button.dart'; +import '/ui/page/login/widget/primary_button.dart'; import '/ui/widget/animated_switcher.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/context_menu/region.dart'; @@ -355,9 +356,7 @@ class UserView extends StatelessWidget { ), ], ContextMenuButton( - onPressed: () { - // TODO: Implement. - }, + onPressed: () => _reportUser(c, context), label: 'btn_report'.l10n, trailing: const SvgIcon(SvgIcons.report), inverted: const SvgIcon(SvgIcons.reportWhite), @@ -515,7 +514,7 @@ class UserView extends StatelessWidget { ActionButton( text: 'btn_report'.l10n, trailing: const SvgIcon(SvgIcons.report16), - onPressed: () {}, + onPressed: () => _reportUser(c, context), ), Obx(() { if (c.isBlocked != null) { @@ -642,4 +641,39 @@ class UserView extends StatelessWidget { await c.block(); } } + + /// Opens a confirmation popup reporting the [User]. + Future _reportUser(UserController c, BuildContext context) async { + final style = Theme.of(context).style; + + final bool? result = await MessagePopup.alert( + 'label_report'.l10n, + description: [ + TextSpan(text: 'alert_user_will_be_reported1'.l10n), + TextSpan( + text: c.user?.title, + style: style.fonts.normal.regular.onBackground, + ), + TextSpan(text: 'alert_user_will_be_reported2'.l10n), + ], + additional: [ + const SizedBox(height: 25), + ReactiveTextField(state: c.reporting, label: 'label_reason'.l10n), + ], + button: (context) { + return Obx(() { + return PrimaryButton( + title: 'btn_proceed'.l10n, + onPressed: c.reporting.isEmpty.value + ? null + : () => Navigator.of(context).pop(true), + ); + }); + }, + ); + + if (result == true) { + await c.report(); + } + } } diff --git a/lib/util/message_popup.dart b/lib/util/message_popup.dart index 9869c5d5348..f57ccd64fb5 100644 --- a/lib/util/message_popup.dart +++ b/lib/util/message_popup.dart @@ -52,6 +52,7 @@ class MessagePopup { String title, { List description = const [], List additional = const [], + Widget Function(BuildContext) button = _defaultButton, }) { final style = Theme.of(router.context!).style; @@ -93,16 +94,7 @@ class MessagePopup { const SizedBox(height: 25), Padding( padding: ModalPopup.padding(context), - child: OutlinedRoundedButton( - key: const Key('Proceed'), - maxWidth: double.infinity, - onPressed: () => Navigator.of(context).pop(true), - color: style.colors.primary, - child: Text( - 'btn_proceed'.l10n, - style: style.fonts.normal.regular.onPrimary, - ), - ), + child: button(context), ), const SizedBox(height: 16), ], @@ -115,4 +107,20 @@ class MessagePopup { /// Shows a [FloatingSnackBar] with the [title] message. static void success(String title, {double bottom = 16}) => FloatingSnackBar.show(title, bottom: bottom); + + /// Returns the proceed button, which invokes [NavigatorState.pop]. + static Widget _defaultButton(BuildContext context) { + final style = Theme.of(context).style; + + return OutlinedRoundedButton( + key: const Key('Proceed'), + maxWidth: double.infinity, + onPressed: () => Navigator.of(context).pop(true), + color: style.colors.primary, + child: Text( + 'btn_proceed'.l10n, + style: style.fonts.normal.regular.onPrimary, + ), + ); + } } From fb95c2573bd253983b849cb741d50873ec3e4621 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 12:52:35 +0300 Subject: [PATCH 09/88] Fix CHANGELOG --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 546f6e21ed0..c3bbb1daa59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,11 @@ All user visible changes to this project will be documented in this file. This p ### Added - UI: - - Account deletion page. ([#944]) - - Terms and conditions page. ([#944]) - - Privacy policy page. ([#944]) + - Account deletion page. ([#961]) + - Terms and conditions page. ([#961]) + - Privacy policy page. ([#961]) -[#944]: /../../pull/944 +[#961]: /../../pull/961 From b4e2afe30e718db88bc9f2edf375e1b3de8cc5ee Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 12:54:02 +0300 Subject: [PATCH 10/88] Add missing English l10n --- assets/l10n/en-US.ftl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index c6bcaa95bf5..45cefd8c9d7 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -32,6 +32,8 @@ alert_chat_will_be_deleted1 = Chat{" "} alert_chat_will_be_deleted2 = {" "}will be deleted. To restore the chat, please, use the search. +alert_chat_will_be_reported1 = Chat{" "} +alert_chat_will_be_reported2 = {" "}will be reported. alert_chats_will_be_deleted = Chats ({$count}) will be deleted. Continue? alert_contact_will_be_removed1 = Contact{" "} alert_contact_will_be_removed2 = {" "}will be deleted. @@ -60,6 +62,8 @@ alert_user_will_be_blocked1 = User{" "} alert_user_will_be_blocked2 = {" "}will be blocked. alert_user_will_be_removed1 = User{" "} alert_user_will_be_removed2 = {" "}will be removed from the group. +alert_user_will_be_reported1 = User{" "} +alert_user_will_be_reported2 = {" "}will be reported. alert_you_will_leave_group = You will leave the group. btn_accept = Accept btn_add = Add From 608bb12618dfb12c11aaf05107f3bebb56d9a0f2 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 15:19:19 +0300 Subject: [PATCH 11/88] Try signing APKs and appbundle --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f844c68514..05c9d06c5ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,19 @@ jobs: SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' if: ${{ matrix.platform != 'web' }} + - uses: Tlaster/android-sign@v1 + name: Sign Android application + with: + releaseDirectory: | + build/app/outputs/flutter-apk/ + app/build/outputs/bundle/release/ + signingKeyBase64: ${{ secrets.ANDROID_KEY }} + output: app/build/outputs/signed/ + alias: ${{ secrets.ANDROID_KEY_ALIAS }} + keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} + keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} + if: ${{ contains('apk appbundle', matrix.platform) }} + - name: Parse application name from Git repository name id: app uses: actions-ecosystem/action-regex-match@v2 From 38ec72aa7abe0e1b5ce5e557ba74683721e4ac3a Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 18:03:57 +0300 Subject: [PATCH 12/88] Remove CI job --- .github/workflows/ci.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c9d06c5ca..9f844c68514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,19 +227,6 @@ jobs: SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' if: ${{ matrix.platform != 'web' }} - - uses: Tlaster/android-sign@v1 - name: Sign Android application - with: - releaseDirectory: | - build/app/outputs/flutter-apk/ - app/build/outputs/bundle/release/ - signingKeyBase64: ${{ secrets.ANDROID_KEY }} - output: app/build/outputs/signed/ - alias: ${{ secrets.ANDROID_KEY_ALIAS }} - keyStorePassword: ${{ secrets.ANDROID_KEY_STORE_PASSWORD }} - keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }} - if: ${{ contains('apk appbundle', matrix.platform) }} - - name: Parse application name from Git repository name id: app uses: actions-ecosystem/action-regex-match@v2 From b19fac678c34aa3a3dd2f035d422c43b83749f1c Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 15 Apr 2024 18:20:05 +0300 Subject: [PATCH 13/88] Try `build.gradle` with signing support --- android/app/build.gradle | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 78cbf2d090e..47263030960 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,6 +22,10 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +def hasKeystore = keystorePropertiesFile.exists() + apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'com.google.gms.google-services' @@ -52,12 +56,25 @@ android { versionName flutterVersionName } + signingConfigs { + if (hasKeystore) { + release { + keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } + storeFile file("$keystoreProperties.storeFile") + storePassword "$keystoreProperties.storePassword" + keyAlias "$keystoreProperties.keyAlias" + keyPassword "$keystoreProperties.keyPassword" + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` - // works. - signingConfig signingConfigs.debug + if (hasKeystore) { + signingConfig signingConfigs.release + } else { + signingConfig signingConfigs.debug + } } } } From 86c59d55a037557de254b70642d10afd9c1c0461 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 09:13:45 +0300 Subject: [PATCH 14/88] Try signing --- .github/workflows/ci.yml | 8 +++++ lib/ui/page/home/page/my_profile/view.dart | 38 +++++++++++++--------- lib/ui/page/login/privacy_policy/view.dart | 4 ++- lib/ui/page/login/terms_of_use/view.dart | 4 ++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f844c68514..84e26fd2fd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,6 +210,10 @@ jobs: SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }}' if: ${{ matrix.platform == 'web' }} + - name: Sign Android application + run: base64 -d ${{ secrets.ANDROID_KEY }} > android/key.properties + if: ${{ contains('apk appbundle', matrix.platform) }} + # TODO: Add the following `split-debug-info`, when self-hosted Sentry # supports debug symbols: # split-debug-info=${{ contains('web windows', matrix.platform) && 'no' || 'yes' }} @@ -306,6 +310,10 @@ jobs: # retention-days: 1 # if: ${{ contains('apk appbundle ios macos web', matrix.platform) }} + - name: Cleanup secret Android signing key + run: rm -rf android/key.properties + if: ${{ always() }} + build-linux: name: build (linux) runs-on: ubuntu-latest diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index ab50ec64700..a5d36451289 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -80,6 +80,8 @@ class MyProfileView extends StatelessWidget { @override Widget build(BuildContext context) { + final style = Theme.of(context).style; + return GetBuilder( key: const Key('MyProfileView'), init: MyProfileController(Get.find(), Get.find()), @@ -322,23 +324,29 @@ class MyProfileView extends StatelessWidget { top: false, right: false, left: false, - child: Column( + child: Block( children: [ - const SizedBox(height: 12), - Center( - child: StyledCupertinoButton( - label: 'btn_terms_and_conditions'.l10n, - onPressed: () => TermsOfUseView.show(context), - ), - ), - const SizedBox(height: 6), - Center( - child: StyledCupertinoButton( - label: 'btn_privacy_policy'.l10n, - onPressed: () => PrivacyPolicy.show(context), - ), + Column( + children: [ + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => + TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 6), + Center( + child: StyledCupertinoButton( + label: 'btn_privacy_policy'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => + PrivacyPolicy.show(context), + ), + ), + ], ), - const SizedBox(height: 16), ], ), ); diff --git a/lib/ui/page/login/privacy_policy/view.dart b/lib/ui/page/login/privacy_policy/view.dart index 4368e773bb5..bab323c8b42 100644 --- a/lib/ui/page/login/privacy_policy/view.dart +++ b/lib/ui/page/login/privacy_policy/view.dart @@ -109,7 +109,9 @@ If you have any questions regarding privacy while using the Application, or have const ModalPopupHeader(), Expanded( child: SingleChildScrollView( - padding: ModalPopup.padding(context), + padding: ModalPopup.padding(context).add( + const EdgeInsets.only(bottom: 16), + ), child: const MarkdownWidget(_text), ), ), diff --git a/lib/ui/page/login/terms_of_use/view.dart b/lib/ui/page/login/terms_of_use/view.dart index 856dadc9d03..c53890c18bc 100644 --- a/lib/ui/page/login/terms_of_use/view.dart +++ b/lib/ui/page/login/terms_of_use/view.dart @@ -72,7 +72,9 @@ If you have any questions or suggestions about the Terms and Conditions, please const ModalPopupHeader(), Expanded( child: SingleChildScrollView( - padding: ModalPopup.padding(context), + padding: ModalPopup.padding(context).add( + const EdgeInsets.only(bottom: 16), + ), child: const MarkdownWidget(_text), ), ), From 391cab7dac6e88fcf4dd174ea3e48247f6c09659 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 09:37:08 +0300 Subject: [PATCH 15/88] Try? --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84e26fd2fd3..a89195dcf41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: if: ${{ matrix.platform == 'web' }} - name: Sign Android application - run: base64 -d ${{ secrets.ANDROID_KEY }} > android/key.properties + run: base64 -d "${{ secrets.ANDROID_KEY }}" > android/key.properties if: ${{ contains('apk appbundle', matrix.platform) }} # TODO: Add the following `split-debug-info`, when self-hosted Sentry From fc85e37f89fb97d00cb1fef9e7346ce0938989b9 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 09:54:16 +0300 Subject: [PATCH 16/88] Fix --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a89195dcf41..23af63ee834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,7 +211,7 @@ jobs: if: ${{ matrix.platform == 'web' }} - name: Sign Android application - run: base64 -d "${{ secrets.ANDROID_KEY }}" > android/key.properties + run: echo ${{ secrets.ANDROID_KEY }} | base64 -d > android/key.properties if: ${{ contains('apk appbundle', matrix.platform) }} # TODO: Add the following `split-debug-info`, when self-hosted Sentry From 39c0eb6fdc06b68c3dbc735c19c3a962fc40b326 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 10:20:43 +0300 Subject: [PATCH 17/88] Try --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23af63ee834..343542e7a6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,8 +210,11 @@ jobs: SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }}' if: ${{ matrix.platform == 'web' }} - - name: Sign Android application - run: echo ${{ secrets.ANDROID_KEY }} | base64 -d > android/key.properties + - name: Prepare Android signing resources + run: | + echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > android/keystore.jks + echo "${{ secrets.ANDROID_PROPERTIES }}" > android/key.properties + if: ${{ contains('apk appbundle', matrix.platform) }} # TODO: Add the following `split-debug-info`, when self-hosted Sentry From 73aabb71f6bbaf32f20bfc19dcf0fba03b9d6246 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 10:40:30 +0300 Subject: [PATCH 18/88] Fix --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 343542e7a6a..3d68151b13f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,7 +212,7 @@ jobs: - name: Prepare Android signing resources run: | - echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > android/keystore.jks + echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > android/app/keystore.jks echo "${{ secrets.ANDROID_PROPERTIES }}" > android/key.properties if: ${{ contains('apk appbundle', matrix.platform) }} From 503163c516d66f10aaebd2a7b03e2684a0b9f9b0 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 11:30:13 +0300 Subject: [PATCH 19/88] Fix cleanup? --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d68151b13f..112a9703eed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,8 +313,14 @@ jobs: # retention-days: 1 # if: ${{ contains('apk appbundle ios macos web', matrix.platform) }} - - name: Cleanup secret Android signing key - run: rm -rf android/key.properties + - name: Cleanup Android signing resources + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + rd /s /q "android/key.properties" + rd /s /q "android/app/keystore.jks" + else + rm -rf android/key.properties android/app/keystore.jks + fi if: ${{ always() }} build-linux: From f83aca045ebe4561042e0d8925d39af392bd7fee Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 12:11:16 +0300 Subject: [PATCH 20/88] Try --- .github/workflows/ci.yml | 11 ++--------- android/app/build.gradle | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 112a9703eed..21d4a52f621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,7 +214,6 @@ jobs: run: | echo "${{ secrets.ANDROID_KEYSTORE }}" | base64 -d > android/app/keystore.jks echo "${{ secrets.ANDROID_PROPERTIES }}" > android/key.properties - if: ${{ contains('apk appbundle', matrix.platform) }} # TODO: Add the following `split-debug-info`, when self-hosted Sentry @@ -314,14 +313,8 @@ jobs: # if: ${{ contains('apk appbundle ios macos web', matrix.platform) }} - name: Cleanup Android signing resources - run: | - if [ "$RUNNER_OS" == "Windows" ]; then - rd /s /q "android/key.properties" - rd /s /q "android/app/keystore.jks" - else - rm -rf android/key.properties android/app/keystore.jks - fi - if: ${{ always() }} + run: rm -rf android/key.properties android/app/keystore.jks + if: ${{ always() && runner.os != 'Windows' }} build-linux: name: build (linux) diff --git a/android/app/build.gradle b/android/app/build.gradle index 47263030960..65ab59567e6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,9 +22,9 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -def hasKeystore = keystorePropertiesFile.exists() +def properties = new Properties() +def propertiesFile = rootProject.file('key.properties') +def hasProperties = propertiesFile.exists() apply plugin: 'com.android.application' apply plugin: 'kotlin-android' @@ -57,20 +57,20 @@ android { } signingConfigs { - if (hasKeystore) { + if (hasProperties) { release { - keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } - storeFile file("$keystoreProperties.storeFile") - storePassword "$keystoreProperties.storePassword" - keyAlias "$keystoreProperties.keyAlias" - keyPassword "$keystoreProperties.keyPassword" + propertiesFile.withInputStream { properties.load(it) } + storeFile file("$properties.storeFile") + storePassword "$properties.storePassword" + keyAlias "$properties.keyAlias" + keyPassword "$properties.keyPassword" } } } buildTypes { release { - if (hasKeystore) { + if (hasProperties) { signingConfig signingConfigs.release } else { signingConfig signingConfigs.debug From c26fc532473fdebdca03d81e5c9620d1f79b79b7 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 16 Apr 2024 12:34:17 +0300 Subject: [PATCH 21/88] Corrections --- {web => assets}/privacy.html | 126 ++++++++++++-------- assets/terms.html | 132 +++++++++++++++++++++ lib/ui/page/home/page/my_profile/view.dart | 57 ++++----- lib/ui/page/login/privacy_policy/view.dart | 116 +++++++----------- lib/ui/page/login/terms_of_use/view.dart | 80 +++++++------ pubspec.lock | 8 ++ pubspec.yaml | 3 + web/terms.html | 101 ---------------- 8 files changed, 333 insertions(+), 290 deletions(-) rename {web => assets}/privacy.html (58%) create mode 100644 assets/terms.html delete mode 100644 web/terms.html diff --git a/web/privacy.html b/assets/privacy.html similarity index 58% rename from web/privacy.html rename to assets/privacy.html index 84a948637b8..dd55e5dc6e0 100644 --- a/web/privacy.html +++ b/assets/privacy.html @@ -34,91 +34,119 @@ Privacy Policy +

This privacy policy applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. This - service is intended for use "AS IS".


Information Collection and Use + service is intended for use "AS IS".

+ + Information Collection and Use +

The Application collects information when you download and use it. This information may include information such as

+
  • Your device's Internet Protocol address (e.g. IP address)
  • The pages of the Application that you visit, the time and date of your visit, the time spent on those pages
  • The time spent on the Application
  • The operating system you use on your mobile device
-


+

The Application does not gather precise information about the location of your mobile device.

-
-

The Application collects your device's location, which helps the Service Provider determine your approximate - geographical location and make use of in below ways:

-
    -
  • Geolocation Services: The Service Provider utilizes location data to provide features such as personalized - content, relevant recommendations, and location-based services.
  • -
  • Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user - behavior, identify trends, and improve the overall performance and functionality of the Application.
  • -
  • Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external - services. These services assist them in enhancing the Application and optimizing their offerings.
  • -
-

+ +

The Application collects your device's location, which helps the Service Provider determine your approximate + geographical location and make use of in below ways:

+ +
    +
  • Geolocation Services: The Service Provider utilizes location data to provide features such as personalized + content, relevant recommendations, and location-based services.
  • +
  • Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user + behavior, identify trends, and improve the overall performance and functionality of the Application.
  • +
  • Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external + services. These services assist them in enhancing the Application and optimizing their offerings.
  • +
+

The Service Provider may use the information you provided to contact you from time to time to provide you with - important information, required notices and marketing promotions.


+ important information, required notices and marketing promotions.

+

For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information, including but not limited to Email, phone number. The information that the Service Provider request will be retained by them and used as described in this privacy policy.

-
Third Party Access + + Third Party Access +

Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement.

-

+ +

Please note that the Application utilizes third-party services that have their own Privacy Policy about + handling data. Below are the links to the Privacy Policy of the third-party service providers used by the + Application:

+ + +

The Service Provider may disclose User Provided and Automatically Collected Information:

+
  • as required by law, such as to comply with a subpoena, or similar legal process;
  • -
  • when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the - safety of others, investigate fraud, or respond to a government request;
  • +
  • when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or + the safety of others, investigate fraud, or respond to a government request;
  • with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement.
-


Opt-Out Rights + + Opt-Out Rights +

You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or - network.


Data Retention Policy + network.

+ + Data Retention Policy +

The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable - time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please + time + thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please proceed to the https://gapopa.com/erase for instructions on how to do so.

-
Children + + Children +

The Service Provider does not use the Application to knowingly solicit data from or market to children under the - age of 13.

-

-

The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect - personally identifiable information from children under 13 years of age. In the case the Service Provider discover - that a child under 13 has provided personal information, the Service Provider will immediately delete this from - their servers. If you are a parent or guardian and you are aware that your child has provided us with personal - information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the - necessary actions.

-

Security + age + of 13.

+ +

The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect + personally identifiable information from children under 13 years of age. In the case the Service Provider discover + that a child under 13 has provided personal information, the Service Provider will immediately delete this from + their servers. If you are a parent or guardian and you are aware that your child has provided us with personal + information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the necessary + actions.

+ + Security +

The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and - maintains.


Changes + maintains.

+ + Changes +

This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this - Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.


-

This privacy policy is effective as of 2024-04-11


Your Consent + Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.

+ +

This privacy policy is effective as of 2024-04-11

+ + Your Consent +

By using the Application, you are consenting to the processing of your information as set forth in this Privacy - Policy now and as amended by us.


Contact Us + Policy now and as amended by us.

+ + Contact Us +

If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at admin@gapopa.com.

-
-

This privacy policy page was generated by App Privacy Policy Generator

\ No newline at end of file diff --git a/assets/terms.html b/assets/terms.html new file mode 100644 index 00000000000..eba7cef2ccd --- /dev/null +++ b/assets/terms.html @@ -0,0 +1,132 @@ + + + + + + + + + Terms & Conditions + + + + + Terms & Conditions +

These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices + that + was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source + service. +

+

Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is + strongly advised that you thoroughly read and understand these terms prior to using the Application.

+

The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As + such, they reserve the right to modify the Application or charge for their services at any time and for any + reason. + The Service Provider assures you that any charges for the Application or its services will be clearly + communicated + to you.

+

The Application stores and processes personal data that you have provided to the Service Provider in order to + provide the Service. It is your responsibility to maintain the security of your phone and access to the + Application. + The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing + software + restrictions and limitations imposed by the official operating system of your device. Such actions could expose + your + phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the + Application not functioning correctly or at all.

+ +
+

Please note that the Application utilizes third-party services that have their own Terms and Conditions. + Below + are the links to the Terms and Conditions of the third-party service providers used by the Application:

+ +
+ + +

Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of + the + Application require an active internet connection, which can be Wi-Fi or provided by your mobile network + provider. + The Service Provider cannot be held responsible if the Application does not function at full capacity due to + lack of + access to Wi-Fi or if you have exhausted your data allowance.

+ + +

If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's + agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during + the + connection to the application, or other third-party charges. By using the application, you accept responsibility + for + any such charges, including roaming data charges if you use the application outside of your home territory + (i.e., + region or country) without disabling data roaming. If you are not the bill payer for the device on which you are + using the application, they assume that you have obtained permission from the bill payer.

+ + +

Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For + instance, it is your responsibility to ensure that your device remains charged. If your device runs out of + battery + and you are unable to access the Service, the Service Provider cannot be held responsible.

+ + +

In terms of the Service Provider's responsibility for your use of the application, it is important to note that + while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to + provide + information to them so that they can make it available to you. The Service Provider accepts no liability for any + loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the + application.

+ + +

The Service Provider may wish to update the application at some point. The application is currently available as + per the requirements for the operating system (and for any additional systems they decide to extend the + availability + of the application to) may change, and you will need to download the updates if you want to continue using the + application. The Service Provider does not guarantee that it will always update the application so that it is + relevant to you and/or compatible with the particular operating system version installed on your device. + However, + you agree to always accept updates to the application when offered to you. The Service Provider may also wish to + cease providing the application and may terminate its use at any time without providing termination notice to + you. + Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these + terms + will end; (b) you must cease using the application, and (if necessary) delete it from your device.

+ + Changes to These Terms and Conditions +

The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review + this + page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and + Conditions on this page.

+

These terms and conditions are effective as of 2024-04-11

+ + Contact Us +

If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the + Service Provider at admin@gapopa.com.

+ + + \ No newline at end of file diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index a5d36451289..0b908fcedf8 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -80,8 +80,6 @@ class MyProfileView extends StatelessWidget { @override Widget build(BuildContext context) { - final style = Theme.of(context).style; - return GetBuilder( key: const Key('MyProfileView'), init: MyProfileController(Get.find(), Get.find()), @@ -324,31 +322,7 @@ class MyProfileView extends StatelessWidget { top: false, right: false, left: false, - child: Block( - children: [ - Column( - children: [ - Center( - child: StyledCupertinoButton( - label: 'btn_terms_and_conditions'.l10n, - style: style.fonts.small.regular.primary, - onPressed: () => - TermsOfUseView.show(context), - ), - ), - const SizedBox(height: 6), - Center( - child: StyledCupertinoButton( - label: 'btn_privacy_policy'.l10n, - style: style.fonts.small.regular.primary, - onPressed: () => - PrivacyPolicy.show(context), - ), - ), - ], - ), - ], - ), + child: _legal(c, context), ); } }, @@ -1063,6 +1037,35 @@ Widget _storage(BuildContext context, MyProfileController c) { ); } +/// Returns the buttons for legal related information displaying. +Widget _legal(MyProfileController c, BuildContext context) { + final style = Theme.of(context).style; + + return Block( + children: [ + Column( + children: [ + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 6), + Center( + child: StyledCupertinoButton( + label: 'btn_privacy_policy'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => PrivacyPolicy.show(context), + ), + ), + ], + ), + ], + ); +} + /// Returns information about the [MyUser]. Widget _bar(MyProfileController c, BuildContext context) { final style = Theme.of(context).style; diff --git a/lib/ui/page/login/privacy_policy/view.dart b/lib/ui/page/login/privacy_policy/view.dart index bab323c8b42..6d39f69ed5a 100644 --- a/lib/ui/page/login/privacy_policy/view.dart +++ b/lib/ui/page/login/privacy_policy/view.dart @@ -16,94 +16,49 @@ // . import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:url_launcher/url_launcher_string.dart'; -import '/ui/widget/markdown.dart'; import '/ui/widget/modal_popup.dart'; /// Privacy policy page. -class PrivacyPolicy extends StatelessWidget { +class PrivacyPolicy extends StatefulWidget { const PrivacyPolicy({super.key}); - /// Text of the privacy policy itself. - static const String _text = '''**Privacy Policy** - -This privacy policy applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. This service is intended for use "AS IS". - -**Information Collection and Use** - -The Application collects information when you download and use it. This information may include information such as - -* Your device's Internet Protocol address (e.g. IP address) -* The pages of the Application that you visit, the time and date of your visit, the time spent on those pages -* The time spent on the Application -* The operating system you use on your mobile device - -The Application does not gather precise information about the location of your mobile device. - -The Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways: - -* Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services. -* Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application. -* Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings. - -The Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions. - -For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information, including but not limited to Email, phone number. The information that the Service Provider request will be retained by them and used as described in this privacy policy. - -**Third Party Access** - -Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement. - -Please note that the Application utilizes third-party services that have their own Privacy Policy about handling data. Below are the links to the Privacy Policy of the third-party service providers used by the Application: - -* [Google Play Services](https://www.google.com/policies/privacy/) -* [Sentry](https://sentry.io/privacy/) - -The Service Provider may disclose User Provided and Automatically Collected Information: - -* as required by law, such as to comply with a subpoena, or similar legal process; -* when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the safety of others, investigate fraud, or respond to a government request; -* with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement. - -**Opt-Out Rights** - -You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network. - -**Data Retention Policy** - -The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please proceed to the https://gapopa.com/erase for instructions on how to do so. - -**Children** - -The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13. - -The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect personally identifiable information from children under 13 years of age. In the case the Service Provider discover that a child under 13 has provided personal information, the Service Provider will immediately delete this from their servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the necessary actions. - -**Security** - -The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains. - -**Changes** - -This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes. - -This privacy policy is effective as of 2024-04-11 - -**Your Consent** + /// Displays a [PrivacyPolicy] wrapped in a [ModalPopup]. + static Future show(BuildContext context) { + return ModalPopup.show(context: context, child: const PrivacyPolicy()); + } -By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us. + @override + State createState() => _PrivacyPolicyState(); +} -**Contact Us** +/// State of a [PrivacyPolicy] loading the [_text] of the privacy policy itself. +class _PrivacyPolicyState extends State { + /// Text of the privacy policy itself. + String? _text; -If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at admin@gapopa.com.'''; + @override + void initState() { + rootBundle.loadString('assets/privacy.html').then( + (value) { + if (mounted) { + setState(() => _text = value); + } + }, + ); - /// Displays a [PrivacyPolicy] wrapped in a [ModalPopup]. - static Future show(BuildContext context) { - return ModalPopup.show(context: context, child: const PrivacyPolicy()); + super.initState(); } @override Widget build(BuildContext context) { + if (_text == null) { + return const CircularProgressIndicator(); + } + return Column( children: [ const ModalPopupHeader(), @@ -112,7 +67,18 @@ If you have any questions regarding privacy while using the Application, or have padding: ModalPopup.padding(context).add( const EdgeInsets.only(bottom: 16), ), - child: const MarkdownWidget(_text), + child: HtmlWidget( + _text!, + onTapUrl: launchUrlString, + customWidgetBuilder: (element) { + // Don't display `` tag, as body already contains header. + if (element.localName == 'title') { + return const SizedBox(); + } + + return null; + }, + ), ), ), ], diff --git a/lib/ui/page/login/terms_of_use/view.dart b/lib/ui/page/login/terms_of_use/view.dart index c53890c18bc..56e72e5133d 100644 --- a/lib/ui/page/login/terms_of_use/view.dart +++ b/lib/ui/page/login/terms_of_use/view.dart @@ -16,57 +16,50 @@ // <https://www.gnu.org/licenses/agpl-3.0.html>. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:url_launcher/url_launcher_string.dart'; -import '/ui/widget/markdown.dart'; import '/ui/widget/modal_popup.dart'; /// Terms and conditions page. -class TermsOfUseView extends StatelessWidget { +class TermsOfUseView extends StatefulWidget { const TermsOfUseView({super.key}); - /// Text of the terms and conditions itself. - static const String _text = '''**Terms & Conditions** - -These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. - -Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is strongly advised that you thoroughly read and understand these terms prior to using the Application. - -The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As such, they reserve the right to modify the Application or charge for their services at any time and for any reason. The Service Provider assures you that any charges for the Application or its services will be clearly communicated to you. - -The Application stores and processes personal data that you have provided to the Service Provider in order to provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software restrictions and limitations imposed by the official operating system of your device. Such actions could expose your phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the Application not functioning correctly or at all. - -Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below are the links to the Terms and Conditions of the third-party service providers used by the Application: - -* [Google Play Services](https://policies.google.com/terms) -* [Sentry](https://sentry.io/terms/) - -Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of access to Wi-Fi or if you have exhausted your data allowance. - -If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the connection to the application, or other third-party charges. By using the application, you accept responsibility for any such charges, including roaming data charges if you use the application outside of your home territory (i.e., region or country) without disabling data roaming. If you are not the bill payer for the device on which you are using the application, they assume that you have obtained permission from the bill payer. - -Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery and you are unable to access the Service, the Service Provider cannot be held responsible. - -In terms of the Service Provider's responsibility for your use of the application, it is important to note that while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide information to them so that they can make it available to you. The Service Provider accepts no liability for any loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the application. - -The Service Provider may wish to update the application at some point. The application is currently available as per the requirements for the operating system (and for any additional systems they decide to extend the availability of the application to) may change, and you will need to download the updates if you want to continue using the application. The Service Provider does not guarantee that it will always update the application so that it is relevant to you and/or compatible with the particular operating system version installed on your device. However, you agree to always accept updates to the application when offered to you. The Service Provider may also wish to cease providing the application and may terminate its use at any time without providing termination notice to you. Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must cease using the application, and (if necessary) delete it from your device. - -**Changes to These Terms and Conditions** - -The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and Conditions on this page. + /// Displays a [TermsOfUseView] wrapped in a [ModalPopup]. + static Future<T?> show<T>(BuildContext context) { + return ModalPopup.show(context: context, child: const TermsOfUseView()); + } -These terms and conditions are effective as of 2024-04-11 + @override + State<TermsOfUseView> createState() => _TermsOfUseViewState(); +} -**Contact Us** +/// State of a [TermsOfUseView] loading the [_text] of the terms and conditions +/// itself. +class _TermsOfUseViewState extends State<TermsOfUseView> { + /// Text of the terms and conditions itself. + String? _text; -If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the Service Provider at admin@gapopa.com.'''; + @override + void initState() { + rootBundle.loadString('assets/terms.html').then( + (value) { + if (mounted) { + setState(() => _text = value); + } + }, + ); - /// Displays a [TermsOfUseView] wrapped in a [ModalPopup]. - static Future<T?> show<T>(BuildContext context) { - return ModalPopup.show(context: context, child: const TermsOfUseView()); + super.initState(); } @override Widget build(BuildContext context) { + if (_text == null) { + return const CircularProgressIndicator(); + } + return Column( children: [ const ModalPopupHeader(), @@ -75,7 +68,18 @@ If you have any questions or suggestions about the Terms and Conditions, please padding: ModalPopup.padding(context).add( const EdgeInsets.only(bottom: 16), ), - child: const MarkdownWidget(_text), + child: HtmlWidget( + _text!, + onTapUrl: launchUrlString, + customWidgetBuilder: (element) { + // Don't display `<title>` tag, as body already contains header. + if (element.localName == 'title') { + return const SizedBox(); + } + + return null; + }, + ), ), ), ], diff --git a/pubspec.lock b/pubspec.lock index b080a3d47af..9b728dfd2b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -665,6 +665,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_widget_from_html_core: + dependency: "direct main" + description: + name: flutter_widget_from_html_core + sha256: "028f4989b9ff4907466af233d50146d807772600d98a3e895662fbdb09c39225" + url: "https://pub.dev" + source: hosted + version: "0.14.11" flutter_xlider: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8ea1b7344ec..41571f0095e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: flutter_thumbhash: ^0.1.0+1 flutter_web_plugins: sdk: flutter + flutter_widget_from_html_core: ^0.14.11 flutter_xlider: ^3.5.0 get: ^4.6.5 graphql_flutter: ^5.2.0-beta.6 @@ -167,6 +168,8 @@ flutter: - assets/images/logo/ - assets/images/media_buttons/ - assets/l10n/ + - assets/privacy.html + - assets/terms.html fonts: - family: Roboto fonts: diff --git a/web/terms.html b/web/terms.html deleted file mode 100644 index 9ad12b369bb..00000000000 --- a/web/terms.html +++ /dev/null @@ -1,101 +0,0 @@ -<!DOCTYPE html> -<!-- - Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, - <https://github.com/team113> - - This program is free software: you can redistribute it and/or modify it under - the terms of the GNU Affero General Public License v3.0 as published by the - Free Software Foundation, either version 3 of the License, or (at your - option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT - ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS - FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for - more details. - - You should have received a copy of the GNU Affero General Public License v3.0 - along with this program. If not, see - <https://www.gnu.org/licenses/agpl-3.0.html>. ---> - -<html> - -<head> - <meta charset='utf-8'> - <meta name='viewport' content='width=device-width'> - <title>Terms & Conditions - - - - - Terms & Conditions
-

These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices that - was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. -


-

Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is - strongly advised that you thoroughly read and understand these terms prior to using the Application.


-

The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As - such, they reserve the right to modify the Application or charge for their services at any time and for any reason. - The Service Provider assures you that any charges for the Application or its services will be clearly communicated - to you.


-

The Application stores and processes personal data that you have provided to the Service Provider in order to - provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. - The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software - restrictions and limitations imposed by the official operating system of your device. Such actions could expose your - phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the - Application not functioning correctly or at all.

-
-

Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below - are the links to the Terms and Conditions of the third-party service providers used by the Application:

- -

-

Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the - Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. - The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of - access to Wi-Fi or if you have exhausted your data allowance.


-

If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's - agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the - connection to the application, or other third-party charges. By using the application, you accept responsibility for - any such charges, including roaming data charges if you use the application outside of your home territory (i.e., - region or country) without disabling data roaming. If you are not the bill payer for the device on which you are - using the application, they assume that you have obtained permission from the bill payer.


-

Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For - instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery - and you are unable to access the Service, the Service Provider cannot be held responsible.


-

In terms of the Service Provider's responsibility for your use of the application, it is important to note that - while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide - information to them so that they can make it available to you. The Service Provider accepts no liability for any - loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the - application.


-

The Service Provider may wish to update the application at some point. The application is currently available as - per the requirements for the operating system (and for any additional systems they decide to extend the availability - of the application to) may change, and you will need to download the updates if you want to continue using the - application. The Service Provider does not guarantee that it will always update the application so that it is - relevant to you and/or compatible with the particular operating system version installed on your device. However, - you agree to always accept updates to the application when offered to you. The Service Provider may also wish to - cease providing the application and may terminate its use at any time without providing termination notice to you. - Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms - will end; (b) you must cease using the application, and (if necessary) delete it from your device.

-
Changes to These Terms and Conditions -

The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this - page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and - Conditions on this page.


-

These terms and conditions are effective as of 2024-04-11


Contact Us -

If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the - Service Provider at admin@gapopa.com.

-
-

This Terms and Conditions page was generated by App Privacy Policy Generator

- - - \ No newline at end of file From 1d8f84b3472032d8aa92932e23e1026b3bb14798 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 17 Apr 2024 09:54:06 +0300 Subject: [PATCH 22/88] Bootstrap --- CHANGELOG.md | 16 ++ assets/l10n/en-US.ftl | 5 + assets/l10n/ru-RU.ftl | 5 + lib/main.dart | 225 +++++++++++---------- lib/provider/hive/consent.dart | 47 +++++ lib/ui/page/consent/controller.dart | 27 +++ lib/ui/page/consent/view.dart | 97 +++++++++ lib/ui/page/work/widget/project_block.dart | 6 +- 8 files changed, 324 insertions(+), 104 deletions(-) create mode 100644 lib/provider/hive/consent.dart create mode 100644 lib/ui/page/consent/controller.dart create mode 100644 lib/ui/page/consent/view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e7fd75d55..95293d88c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ All user visible changes to this project will be documented in this file. This p +## [0.1.0-alpha.14] · 2024-??-?? +[0.1.0-alpha.14]: /../../tree/v0.1.0-alpha.14 + +[Diff](/../../compare/v0.1.0-alpha.13.2...v0.1.0-alpha.14) | [Milestone](/../../milestone/21) + +### Added + +- UI: + - [Sentry] usage consent screen on mobile platforms. ([#961]) + +[#961]: /../../pull/961 + + + + ## [0.1.0-alpha.13.2] · 2024-04-11 [0.1.0-alpha.13.2]: /../../tree/v0.1.0-alpha.13.2 @@ -1087,3 +1102,4 @@ All user visible changes to this project will be documented in this file. This p [Helm]: https://helm.sh [PWA]: https://en.wikipedia.org/wiki/Progressive_web_app [Semantic Versioning 2.0.0]: https://semver.org +[Sentry]: https://sentry.io diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index da8911cadf8..7f0b2c3897a 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -549,6 +549,11 @@ label_ago_date = {$years -> *[other] {$years} years ago } label_all = All +label_anonymous_reports = Anonymous reports +label_anonymous_reports_description = + Application may collect anonymously technical performance data and detailed reports of errors happening, if any. + + Allow application to collect and send such data? label_app_background = Application background label_application = Application label_are_you_sure_no = No diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index cf5521d5f89..ad9365bfd5a 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -572,6 +572,11 @@ label_ago_date = {$years -> *[other] {$years} лет назад } label_all = Все +label_anonymous_reports = Анонимные отчёты +label_anonymous_reports_description = + Приложение может собирать анонимно технические данные работы приложения и подробные отчёты об ошибках в случае их возникновения. + + Разрешить приложению собирать и отправлять эти данные? label_app_background = Фон приложения label_application = Приложение label_are_you_sure_no = Нет diff --git a/lib/main.dart b/lib/main.dart index 121a6121a73..c558b859809 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -52,6 +52,7 @@ import 'l10n/l10n.dart'; import 'provider/gql/exceptions.dart'; import 'provider/gql/graphql.dart'; import 'provider/hive/cache.dart'; +import 'provider/hive/consent.dart'; import 'provider/hive/credentials.dart'; import 'provider/hive/download.dart'; import 'provider/hive/skipped_version.dart'; @@ -61,6 +62,7 @@ import 'routes.dart'; import 'store/auth.dart'; import 'store/model/window_preferences.dart'; import 'themes.dart'; +import 'ui/page/consent/view.dart'; import 'ui/worker/cache.dart'; import 'ui/worker/upgrade.dart'; import 'ui/worker/window.dart'; @@ -83,32 +85,32 @@ Future main() async { dateStamp: !PlatformUtils.isWeb, ); - // Initializes and runs the [App]. - Future appRunner() async { - MediaKit.ensureInitialized(); - WebUtils.setPathUrlStrategy(); + await _initHive(); - await _initHive(); + if (PlatformUtils.isDesktop && !PlatformUtils.isWeb) { + await windowManager.ensureInitialized(); + await windowManager.setMinimumSize(const Size(400, 400)); - if (PlatformUtils.isDesktop && !PlatformUtils.isWeb) { - await windowManager.ensureInitialized(); - await windowManager.setMinimumSize(const Size(400, 400)); + final WindowPreferencesHiveProvider preferences = Get.find(); + final WindowPreferences? prefs = preferences.get(); - final WindowPreferencesHiveProvider preferences = Get.find(); - final WindowPreferences? prefs = preferences.get(); + if (prefs?.size != null) { + await windowManager.setSize(prefs!.size!); + } - if (prefs?.size != null) { - await windowManager.setSize(prefs!.size!); - } + if (prefs?.position != null) { + await windowManager.setPosition(prefs!.position!); + } - if (prefs?.position != null) { - await windowManager.setPosition(prefs!.position!); - } + await windowManager.show(); - await windowManager.show(); + Get.put(WindowWorker(preferences)); + } - Get.put(WindowWorker(preferences)); - } + // Initializes and runs the [App]. + Future appRunner() async { + MediaKit.ensureInitialized(); + WebUtils.setPathUrlStrategy(); final graphQlProvider = Get.put(GraphQlProvider()); @@ -134,99 +136,112 @@ Future main() async { ); } - // No need to initialize the Sentry if no DSN is provided, otherwise useless - // messages are printed to the console every time the application starts. - if (Config.sentryDsn.isEmpty || kDebugMode) { - return appRunner(); - } + // Initializes and runs [Sentry], if [enabled]. + Future sentryRunner(bool enabled) async { + // No need to initialize the Sentry if no DSN is provided, otherwise useless + // messages are printed to the console every time the application starts. + if (!enabled || Config.sentryDsn.isEmpty || kDebugMode) { + return appRunner(); + } - await SentryFlutter.init( - (options) { - options.dsn = Config.sentryDsn; - options.tracesSampleRate = 1.0; - options.sampleRate = 1.0; - options.release = - '${Pubspec.name}@${Pubspec.ref ?? Config.version ?? Pubspec.version}'; - options.debug = true; - options.diagnosticLevel = SentryLevel.info; - options.enablePrintBreadcrumbs = true; - options.maxBreadcrumbs = 512; - options.enableTimeToFullDisplayTracing = true; - options.enableAppHangTracking = true; - options.enableTracing = true; - options.beforeSend = (SentryEvent event, {Hint? hint}) { - final exception = event.exceptions?.firstOrNull?.throwable; - - // Connection related exceptions shouldn't be logged. - if (exception is ConnectionException || - exception is SocketException || - exception is WebSocketException || - exception is WebSocketChannelException || - exception is HttpException || - exception is ClientException || - exception is DioException || - exception is TimeoutException || - exception is ResubscriptionRequiredException) { - return null; - } + await SentryFlutter.init( + (options) { + options.dsn = Config.sentryDsn; + options.tracesSampleRate = 1.0; + options.sampleRate = 1.0; + options.release = + '${Pubspec.name}@${Pubspec.ref ?? Config.version ?? Pubspec.version}'; + options.debug = true; + options.diagnosticLevel = SentryLevel.info; + options.enablePrintBreadcrumbs = true; + options.maxBreadcrumbs = 512; + options.enableTimeToFullDisplayTracing = true; + options.enableAppHangTracking = true; + options.enableTracing = true; + options.beforeSend = (SentryEvent event, {Hint? hint}) { + final exception = event.exceptions?.firstOrNull?.throwable; + + // Connection related exceptions shouldn't be logged. + if (exception is ConnectionException || + exception is SocketException || + exception is WebSocketException || + exception is WebSocketChannelException || + exception is HttpException || + exception is ClientException || + exception is DioException || + exception is TimeoutException || + exception is ResubscriptionRequiredException) { + return null; + } - // [Backoff] related exceptions shouldn't be logged. - if (exception is OperationCanceledException || - exception.toString() == 'Data is not loaded') { - return null; - } + // [Backoff] related exceptions shouldn't be logged. + if (exception is OperationCanceledException || + exception.toString() == 'Data is not loaded') { + return null; + } + + return event; + }; + options.logger = ( + SentryLevel level, + String message, { + String? logger, + Object? exception, + StackTrace? stackTrace, + }) { + if (exception != null) { + if (stackTrace == null) { + stackTrace = StackTrace.current; + } else { + stackTrace = FlutterError.demangleStackTrace(stackTrace); + } + + final Iterable lines = + stackTrace.toString().trimRight().split('\n').take(100); - return event; - }; - options.logger = ( - SentryLevel level, - String message, { - String? logger, - Object? exception, - StackTrace? stackTrace, - }) { - if (exception != null) { - if (stackTrace == null) { - stackTrace = StackTrace.current; - } else { - stackTrace = FlutterError.demangleStackTrace(stackTrace); + Log.error( + [ + exception.toString(), + if (lines.where((e) => e.isNotEmpty).isNotEmpty) + FlutterError.defaultStackFilter(lines).join('\n') + ].join('\n'), + ); } + }; + }, + appRunner: appRunner, + ); - final Iterable lines = - stackTrace.toString().trimRight().split('\n').take(100); + // TODO: Remove, when Sentry supports app start measurement for all platforms. + // ignore: invalid_use_of_internal_member + NativeAppStartIntegration.setAppStartInfo( + AppStartInfo( + AppStartType.cold, + start: DateTime.now().subtract(watch.elapsed), + end: DateTime.now(), + ), + ); - Log.error( - [ - exception.toString(), - if (lines.where((e) => e.isNotEmpty).isNotEmpty) - FlutterError.defaultStackFilter(lines).join('\n') - ].join('\n'), - ); - } - }; - }, - appRunner: appRunner, - ); + // Transaction indicating Flutter engine has rasterized the first frame. + final ISentrySpan ready = Sentry.startTransaction( + 'ui.app.ready', + 'ui', + autoFinishAfter: const Duration(minutes: 2), + startTimestamp: DateTime.now().subtract(watch.elapsed), + )..startChild('ready'); - // TODO: Remove, when Sentry supports app start measurement for all platforms. - // ignore: invalid_use_of_internal_member - NativeAppStartIntegration.setAppStartInfo( - AppStartInfo( - AppStartType.cold, - start: DateTime.now().subtract(watch.elapsed), - end: DateTime.now(), - ), - ); + WidgetsBinding.instance.waitUntilFirstFrameRasterized + .then((_) => ready.finish()); + } - // Transaction indicating Flutter engine has rasterized the first frame. - final ISentrySpan ready = Sentry.startTransaction( - 'ui.app.ready', - 'ui', - autoFinishAfter: const Duration(minutes: 2), - )..startChild('ready'); + final consentProvider = Get.findOrNull(); + final consent = consentProvider == null ? true : consentProvider.get(); - WidgetsBinding.instance.waitUntilFirstFrameRasterized - .then((_) => ready.finish()); + if (consent != null) { + await sentryRunner(consent); + } else { + runApp(MaterialApp(theme: Themes.light(), home: ConsentView(sentryRunner))); + } } /// Initializes the [FlutterCallkeep] and displays an incoming call @@ -409,6 +424,10 @@ Future _initHive() async { await Get.put(CacheInfoHiveProvider()).init(); await Get.put(DownloadHiveProvider()).init(); } + + if (PlatformUtils.isIOS || PlatformUtils.isAndroid) { + await Get.put(ConsentHiveProvider()).init(); + } } /// Extension adding an ability to clean [Hive]. diff --git a/lib/provider/hive/consent.dart b/lib/provider/hive/consent.dart new file mode 100644 index 00000000000..59a3582c3ee --- /dev/null +++ b/lib/provider/hive/consent.dart @@ -0,0 +1,47 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'package:hive_flutter/adapters.dart'; + +import '/util/log.dart'; +import 'base.dart'; + +/// [Hive] storage for a consent boolean storage. +class ConsentHiveProvider extends HiveBaseProvider { + @override + Stream get boxEvents => box.watch(key: 0); + + @override + String get boxName => 'consent'; + + @override + void registerAdapters() { + Log.debug('registerAdapters()', '$runtimeType'); + } + + /// Returns the stored consent value from [Hive]. + bool? get() { + Log.trace('get()', '$runtimeType'); + return getSafe(0); + } + + /// Stores new consent [value] to [Hive]. + Future set(bool value) async { + Log.trace('set($value)', '$runtimeType'); + await putSafe(0, value); + } +} diff --git a/lib/ui/page/consent/controller.dart b/lib/ui/page/consent/controller.dart new file mode 100644 index 00000000000..d9595e89658 --- /dev/null +++ b/lib/ui/page/consent/controller.dart @@ -0,0 +1,27 @@ +import 'package:get/get.dart'; + +import '/provider/hive/consent.dart'; + +/// Controller of a [ConsentView]. +class ConsentController extends GetxController { + ConsentController(this._consentProvider, this.callback); + + /// Status of the [proceed] completing the [callback]. + final Rx status = Rx(RxStatus.empty()); + + /// Function to call after acquiring the user's consent. + final Future Function(bool) callback; + + /// [ConsentHiveProvider] providing and storing the consent itself. + final ConsentHiveProvider _consentProvider; + + /// Stores the [consent] and invokes the [callback]. + Future proceed(bool consent) async { + status.value = RxStatus.loading(); + + await _consentProvider.set(consent); + await callback(consent); + + status.value = RxStatus.success(); + } +} diff --git a/lib/ui/page/consent/view.dart b/lib/ui/page/consent/view.dart new file mode 100644 index 00000000000..9b944ac6d74 --- /dev/null +++ b/lib/ui/page/consent/view.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '/l10n/l10n.dart'; +import '/themes.dart'; +import '/ui/page/auth/widget/cupertino_button.dart'; +import '/ui/page/login/widget/primary_button.dart'; +import '/ui/page/work/widget/project_block.dart'; +import '/ui/widget/svg/svg.dart'; +import 'controller.dart'; + +/// Page for acquiring user's consent about [Sentry] data collection. +class ConsentView extends StatelessWidget { + const ConsentView(this.callback, {super.key}); + + /// Callback, called when consent is acquired. + final Future Function(bool) callback; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).style; + + return GetBuilder( + init: ConsentController(Get.find(), callback), + builder: (ConsentController c) { + return Stack( + fit: StackFit.expand, + children: [ + IgnorePointer( + child: ColoredBox(color: style.colors.background), + ), + const IgnorePointer( + child: SvgImage.asset( + 'assets/images/background_light.svg', + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + ), + Obx(() { + if (c.status.value.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Scaffold( + appBar: AppBar(title: Text('label_anonymous_reports'.l10n)), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + shrinkWrap: true, + children: [ + ProjectBlock( + children: [ + const SizedBox(height: 16), + Text( + 'label_anonymous_reports_description'.l10n, + style: + style.fonts.medium.regular.onBackground, + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: PrimaryButton( + title: 'btn_allow'.l10n, + onPressed: () => c.proceed(true), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: StyledCupertinoButton( + onPressed: () => c.proceed(false), + label: 'btn_do_not_allow'.l10n, + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + }), + ], + ); + }, + ); + } +} diff --git a/lib/ui/page/work/widget/project_block.dart b/lib/ui/page/work/widget/project_block.dart index 7605ac578ef..4e47a2fb203 100644 --- a/lib/ui/page/work/widget/project_block.dart +++ b/lib/ui/page/work/widget/project_block.dart @@ -24,7 +24,10 @@ import 'interactive_logo.dart'; /// [Block] display the [InteractiveLogo]. class ProjectBlock extends StatelessWidget { - const ProjectBlock({super.key}); + const ProjectBlock({super.key, this.children = const []}); + + /// [Widget]s to display under the [InteractiveLogo], if any. + final List children; @override Widget build(BuildContext context) { @@ -50,6 +53,7 @@ class ProjectBlock extends StatelessWidget { const SizedBox(height: 20), const InteractiveLogo(), const SizedBox(height: 7), + ...children, ], ); } From fbfca928be0c82ebae481c21cbab999bcfb2bdc6 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 17 Apr 2024 10:19:51 +0300 Subject: [PATCH 23/88] Add `Consent` screen --- CHANGELOG.md | 3 +- assets/icons/menu_legal.svg | 1 + assets/l10n/en-US.ftl | 1 + assets/l10n/ru-RU.ftl | 1 + lib/l10n/l10n.dart | 1 + lib/main.dart | 2 +- lib/routes.dart | 1 + lib/ui/page/home/page/my_profile/view.dart | 43 +++++++++++----------- lib/ui/widget/menu_button.dart | 3 ++ lib/ui/widget/svg/svgs.dart | 6 +++ 10 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 assets/icons/menu_legal.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5bbcf9650..ca34f35bebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,12 @@ All user visible changes to this project will be documented in this file. This p ### Added +- iOS: + - [Sentry] usage consent screen. ([#961]) - UI: - Account deletion page. ([#961]) - Terms and conditions page. ([#961]) - Privacy policy page. ([#961]) - - [Sentry] usage consent screen on mobile platforms. ([#961]) [#961]: /../../pull/961 diff --git a/assets/icons/menu_legal.svg b/assets/icons/menu_legal.svg new file mode 100644 index 00000000000..87cd3744bb2 --- /dev/null +++ b/assets/icons/menu_legal.svg @@ -0,0 +1 @@ + diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index 146478062f5..16d5c1dd120 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -787,6 +787,7 @@ label_language = Language label_language_entry = {$code}, {$name} label_last_seen = Last seen label_leave_group = Leave group +label_legal_information = Legal information label_link_to_chat = Chat link label_load_images = Load images label_login = Login diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 27eaa39bf12..4a64df91c57 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -811,6 +811,7 @@ label_kb = {$amount} КБ label_language = Язык label_language_entry = {$code}, {$name} label_leave_group = Покинуть группу +label_legal_information = Юридическая информация label_link_to_chat = Ссылка на чат label_load_images = Загружать изображения label_login = Логин diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index fd36076f61e..7d83ab1d7ff 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -306,6 +306,7 @@ extension L10nProfileTabExtension on ProfileTab { ProfileTab.sections => 'label_show_sections'.l10n, ProfileTab.download => 'label_download'.l10n, ProfileTab.danger => 'label_danger_zone'.l10n, + ProfileTab.legal => 'label_legal_information'.l10n, ProfileTab.logout => 'btn_logout'.l10n, }; } diff --git a/lib/main.dart b/lib/main.dart index e19a322d637..f1ff106db33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -463,7 +463,7 @@ Future _initHive() async { await Get.put(DownloadHiveProvider()).init(); } - if (PlatformUtils.isIOS || PlatformUtils.isAndroid) { + if (PlatformUtils.isIOS) { await Get.put(ConsentHiveProvider()).init(); } } diff --git a/lib/routes.dart b/lib/routes.dart index 862c717f02b..8c355a3421c 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -132,6 +132,7 @@ enum ProfileTab { sections, download, danger, + legal, logout, } diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index 0b908fcedf8..aef370f5a04 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -317,12 +317,15 @@ class MyProfileView extends StatelessWidget { case ProfileTab.danger: return block(children: [_danger(context, c)]); + case ProfileTab.legal: + return block(children: [_legal(context, c)]); + case ProfileTab.logout: - return SafeArea( + return const SafeArea( top: false, right: false, left: false, - child: _legal(c, context), + child: SizedBox(), ); } }, @@ -1038,29 +1041,25 @@ Widget _storage(BuildContext context, MyProfileController c) { } /// Returns the buttons for legal related information displaying. -Widget _legal(MyProfileController c, BuildContext context) { +Widget _legal(BuildContext context, MyProfileController c) { final style = Theme.of(context).style; - return Block( + return Column( children: [ - Column( - children: [ - Center( - child: StyledCupertinoButton( - label: 'btn_terms_and_conditions'.l10n, - style: style.fonts.small.regular.primary, - onPressed: () => TermsOfUseView.show(context), - ), - ), - const SizedBox(height: 6), - Center( - child: StyledCupertinoButton( - label: 'btn_privacy_policy'.l10n, - style: style.fonts.small.regular.primary, - onPressed: () => PrivacyPolicy.show(context), - ), - ), - ], + Center( + child: StyledCupertinoButton( + label: 'btn_terms_and_conditions'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => TermsOfUseView.show(context), + ), + ), + const SizedBox(height: 12), + Center( + child: StyledCupertinoButton( + label: 'btn_privacy_policy'.l10n, + style: style.fonts.small.regular.primary, + onPressed: () => PrivacyPolicy.show(context), + ), ), ], ); diff --git a/lib/ui/widget/menu_button.dart b/lib/ui/widget/menu_button.dart index 8ae66fb5126..227fc48fbb5 100644 --- a/lib/ui/widget/menu_button.dart +++ b/lib/ui/widget/menu_button.dart @@ -58,6 +58,7 @@ class MenuButton extends StatelessWidget { ProfileTab.sections => 'label_navigation_panel'.l10n, ProfileTab.download => 'label_application'.l10n, ProfileTab.danger => 'label_delete_account'.l10n, + ProfileTab.legal => 'btn_terms_and_conditions'.l10n, ProfileTab.logout => 'label_end_session'.l10n, }, leading = switch (tab) { @@ -76,6 +77,7 @@ class MenuButton extends StatelessWidget { ProfileTab.notifications => const SvgIcon(SvgIcons.menuNotifications), ProfileTab.signing => const SvgIcon(SvgIcons.menuSigning), ProfileTab.storage => const SvgIcon(SvgIcons.menuStorage), + ProfileTab.legal => const SvgIcon(SvgIcons.menuLegal), }, super( key: key ?? @@ -94,6 +96,7 @@ class MenuButton extends StatelessWidget { ProfileTab.sections => const Key('Sections'), ProfileTab.download => const Key('Download'), ProfileTab.danger => const Key('DangerZone'), + ProfileTab.legal => const Key('Legal'), ProfileTab.logout => const Key('LogoutButton'), }, ); diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index 7e5bd909d8e..75ee6e3b47e 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1142,6 +1142,12 @@ class SvgIcons { height: 32, ); + static const SvgData menuLegal = SvgData( + 'assets/icons/menu_legal.svg', + width: 32, + height: 32, + ); + static const List head = [ SvgData( 'assets/images/logo/head_0.svg', From 40a28a6be6213da20d540d8c9762c853e24c6292 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 17 Apr 2024 11:43:13 +0300 Subject: [PATCH 24/88] Corrections --- assets/l10n/en-US.ftl | 1 + assets/l10n/ru-RU.ftl | 1 + ios/Podfile.lock | 16 +++++++++------ lib/main.dart | 32 ++++++++++++++++++++--------- lib/ui/page/consent/controller.dart | 17 +++++++++++++++ lib/ui/page/consent/view.dart | 17 +++++++++++++++ 6 files changed, 68 insertions(+), 16 deletions(-) diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index 16d5c1dd120..d10a3e286d8 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -186,6 +186,7 @@ btn_delete_from_contacts = Delete from contacts btn_delete_from_favorites = Remove from favorites btn_delete_message = Delete message btn_dismiss = Dismiss +btn_do_not_allow = Do not allow btn_download = Download btn_download_all = Download all btn_download_as = Download as diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 4a64df91c57..59f0848f3db 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -186,6 +186,7 @@ btn_delete_from_contacts = Удалить из контактов btn_delete_from_favorites = Удалить из избранных btn_delete_message = Удалить сообщение btn_dismiss = Запретить +btn_do_not_allow = Не разрешать btn_download = Скачать btn_download_all = Скачать всё btn_download_all_as = Скачать всё как diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 64e962a854a..b793774b5e9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,11 +3,10 @@ PODS: - Flutter - audio_session (0.0.1): - Flutter - - callkeep (0.0.1): - - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift + - CryptoSwift (1.8.2) - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.4): @@ -81,6 +80,9 @@ PODS: - Flutter - flutter_background_service_ios (0.0.3): - Flutter + - flutter_callkit_incoming (0.0.1): + - CryptoSwift + - Flutter - flutter_local_notifications (0.0.1): - Flutter - GoogleDataTransport (9.4.1): @@ -182,7 +184,6 @@ PODS: DEPENDENCIES: - all_sensors (from `.symlinks/plugins/all_sensors/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - - callkeep (from `.symlinks/plugins/callkeep/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -192,6 +193,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) + - flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -218,6 +220,7 @@ DEPENDENCIES: SPEC REPOS: trunk: + - CryptoSwift - DKImagePickerController - DKPhotoGallery - Firebase @@ -242,8 +245,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/all_sensors/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" - callkeep: - :path: ".symlinks/plugins/callkeep/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -260,6 +261,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_app_badger/ios" flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" + flutter_callkit_incoming: + :path: ".symlinks/plugins/flutter_callkit_incoming/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" image_gallery_saver: @@ -310,8 +313,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: all_sensors: 13f46502204bebbd1e06b65ff3d7cdaa03d88b26 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - callkeep: 6b21abbd46786a36e482190816db99b391ada1e3 connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d + CryptoSwift: c63a805d8bb5e5538e88af4e44bb537776af11ea device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 @@ -326,6 +329,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_app_badger: b87fc231847b03b92ce1412aa351842e7e97932f flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac + flutter_callkit_incoming: 417dd1b46541cdd5d855ad795ccbe97d1c18155e flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 diff --git a/lib/main.dart b/lib/main.dart index f1ff106db33..120123c9406 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -111,6 +111,8 @@ Future main() async { Get.put(WindowWorker(preferences)); } + await L10n.init(); + // Initializes and runs the [App]. Future appRunner() async { MediaKit.ensureInitialized(); @@ -124,7 +126,6 @@ Future main() async { router = RouterState(authService); authService.init(); - await L10n.init(); Get.put(CacheWorker(Get.findOrNull(), Get.findOrNull())); Get.put(UpgradeWorker(Get.findOrNull())); @@ -142,9 +143,7 @@ Future main() async { // Initializes and runs [Sentry], if [enabled]. Future sentryRunner(bool enabled) async { - // No need to initialize the Sentry if no DSN is provided, otherwise useless - // messages are printed to the console every time the application starts. - if (!enabled || Config.sentryDsn.isEmpty || kDebugMode) { + if (!enabled) { return appRunner(); } @@ -238,13 +237,26 @@ Future main() async { .then((_) => ready.finish()); } - final consentProvider = Get.findOrNull(); - final consent = consentProvider == null ? true : consentProvider.get(); - - if (consent != null) { - await sentryRunner(consent); + // No need to initialize the Sentry if no DSN is provided, otherwise useless + // messages are printed to the console every time the application starts. + if (Config.sentryDsn.isEmpty || kDebugMode) { + return appRunner(); } else { - runApp(MaterialApp(theme: Themes.light(), home: ConsentView(sentryRunner))); + final consentProvider = Get.findOrNull(); + final consent = consentProvider == null ? true : consentProvider.get(); + + if (consent != null) { + // If user has already provided the consent, then try running [Sentry]. + await sentryRunner(consent); + } else { + // Or otherwise ask the user for the consent of [Sentry] usage. + runApp( + MaterialApp( + theme: Themes.light(), + home: ConsentView(sentryRunner), + ), + ); + } } } diff --git a/lib/ui/page/consent/controller.dart b/lib/ui/page/consent/controller.dart index d9595e89658..25e1e06db28 100644 --- a/lib/ui/page/consent/controller.dart +++ b/lib/ui/page/consent/controller.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:get/get.dart'; import '/provider/hive/consent.dart'; diff --git a/lib/ui/page/consent/view.dart b/lib/ui/page/consent/view.dart index 9b944ac6d74..81c0616f9e0 100644 --- a/lib/ui/page/consent/view.dart +++ b/lib/ui/page/consent/view.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:flutter/material.dart'; import 'package:get/get.dart'; From a104523d0d36e9da813974e946d5ee69dac44afb Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 17 Apr 2024 18:18:17 +0300 Subject: [PATCH 25/88] Improve implementation --- .github/workflows/ci.yml | 68 +------------ Makefile | 12 ++- assets/l10n/en-US.ftl | 5 + assets/l10n/ru-RU.ftl | 5 + lib/ui/page/home/introduction/view.dart | 47 +++++++-- lib/ui/page/login/view.dart | 49 ++++++++-- web/privacy.html | 124 ++++++++++++++++++++++++ web/terms.html | 82 ++++++++++++++++ 8 files changed, 313 insertions(+), 79 deletions(-) create mode 100644 web/privacy.html create mode 100644 web/terms.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec68187ddad..c87a317da0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -590,16 +590,9 @@ jobs: docker-push: name: docker push - if: ${{ github.ref == 'refs/heads/main' - || startsWith(github.ref, 'refs/tags/v') }} + if: ${{ github.ref == 'refs/heads/stable-design' }} needs: - - copyright - - dartanalyze - - dartdoc - - dartfmt - docker - - test-e2e - - test-unit strategy: fail-fast: false matrix: @@ -643,68 +636,17 @@ jobs: || secrets.GITHUB_TOKEN }} if: ${{ steps.skip.outputs.no == 'true' }} - - name: Parse semver versions from Git tag - id: semver - uses: actions-ecosystem/action-regex-match@v2 - with: - text: ${{ github.ref }} - regex: '^refs/tags/v(((([0-9]+)\.[0-9]+)\.[0-9]+)(-.+)?)$' - if: ${{ steps.skip.outputs.no == 'true' - && startsWith(github.ref, 'refs/tags/v') }} - - name: Form version Docker tags - id: tags - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - let versions = '${{ steps.semver.outputs.group1 }}'; - if ('${{ steps.semver.outputs.group5 }}' === '') { - versions += ',${{ steps.semver.outputs.group3 }}'; - if ('${{ steps.semver.outputs.group4 }}' !== '0') { - versions += ',${{ steps.semver.outputs.group4 }}'; - } - versions += 'latest'; - } - return versions; - if: ${{ steps.skip.outputs.no == 'true' - && startsWith(github.ref, 'refs/tags/v') }} - - run: make docker.tags of=build-${{ github.run_number }} registries=${{ matrix.registry }} - tags=${{ (startsWith(github.ref, 'refs/tags/v') - && steps.tags.outputs.result) - || 'edge' }} + tags=${{ 'stable-design' }} + as=review if: ${{ steps.skip.outputs.no == 'true' }} - run: make docker.push registries=${{ matrix.registry }} - tags=${{ (startsWith(github.ref, 'refs/tags/v') - && steps.tags.outputs.result) - || 'edge' }} + tags=${{ 'stable-design' }} + image=review if: ${{ steps.skip.outputs.no == 'true' }} - # On GitHub Container Registry README is automatically updated on pushes. - - name: Update README on Docker Hub - uses: christian-korneck/update-container-description-action@v1 - with: - provider: dockerhub - destination_container_repo: ${{ github.repository }} - readme_file: README.md - env: - DOCKER_USER: ${{ secrets.DOCKERHUB_BOT_USER }} - DOCKER_PASS: ${{ secrets.DOCKERHUB_BOT_PASS }} - if: ${{ steps.skip.outputs.no == 'true' - && matrix.registry == 'docker.io' }} - - name: Update README on Quay.io - uses: christian-korneck/update-container-description-action@v1 - with: - provider: quay - destination_container_repo: ${{ matrix.registry }}/${{ github.repository }} - readme_file: README.md - env: - DOCKER_APIKEY: ${{ secrets.QUAYIO_API_TOKEN }} - if: ${{ steps.skip.outputs.no == 'true' - && matrix.registry == 'quay.io' }} - helm-push: name: helm push if: ${{ startsWith(github.ref, 'refs/tags/helm/') }} diff --git a/Makefile b/Makefile index 13dd092437d..0149c1fd723 100644 --- a/Makefile +++ b/Makefile @@ -795,7 +795,6 @@ endif --values=$(helm-chart-vals-dir)/$(helm-cluster).vals.yaml \ --values=my.$(helm-cluster).vals.yaml \ $(if $(call eq,$(helm-cluster),review),\ - --set ingress.hosts={"$(helm-review-app-domain)"} \ --set image.tag="$(CURRENT_BRANCH)" )\ --set deployment.revision=$(shell date +%s) \ $(if $(call eq,$(force),yes),--force,)\ @@ -888,6 +887,17 @@ sentry.upload: +fcm-vapid=$(strip $(shell grep 'FCM_VAPID=' .env | cut -d'=' -f2)) +link-prefix=$(strip $(shell grep 'LINK_PREFIX=' .env | cut -d'=' -f2)) + +deploy: + make build platform=web profile=yes \ + dart-env=SOCAPP_FCM_VAPID_KEY=$(fcm-vapid),SOCAPP_SENTRY_DSN=$(sentry-dsn),SOCAPP_LINK_PREFIX=$(link-prefix) && \ + make helm.up cluster=review rebuild=yes buildx=yes + + + + ################## # .PHONY section # ################## diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index d10a3e286d8..cd4c2c31088 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -24,6 +24,11 @@ alert_are_you_sure_want_to_delete_phone = Are you sure you want to delete this phone number? alert_are_you_sure_want_to_log_out1 = Are you sure you want to sign out of account{" "} alert_are_you_sure_want_to_log_out2 = ? +alert_by_proceeding_you_accept_terms1 = By proceeding, you agree with the{" "} +alert_by_proceeding_you_accept_terms2 = Terms of Service +alert_by_proceeding_you_accept_terms3 = {" "}and{" "} +alert_by_proceeding_you_accept_terms4 = Privacy Policy +alert_by_proceeding_you_accept_terms5 = {" "}of the service. alert_chat_will_be_blocked1 = Chat{" "} alert_chat_will_be_blocked2 = {" "}will be blocked. alert_chat_will_be_cleared1 = Chat{" "} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 59f0848f3db..d9c4b238f3b 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -24,6 +24,11 @@ alert_are_you_sure_want_to_delete_phone = Вы действительно хотите удалить этот номер телефона? alert_are_you_sure_want_to_log_out1 = Вы действительно хотите выйти из аккаунта{" "} alert_are_you_sure_want_to_log_out2 = ? +alert_by_proceeding_you_accept_terms1 = Продолжая, Вы соглашаетесь с{" "} +alert_by_proceeding_you_accept_terms2 = условиями использования +alert_by_proceeding_you_accept_terms3 = {" "}и{" "} +alert_by_proceeding_you_accept_terms4 = политикой кофиденциальности +alert_by_proceeding_you_accept_terms5 = {" "}сервиса. alert_chat_will_be_blocked1 = Чат{" "} alert_chat_will_be_blocked2 = {" "}будет заблокирован. alert_chat_will_be_cleared1 = Чат{" "} diff --git a/lib/ui/page/home/introduction/view.dart b/lib/ui/page/home/introduction/view.dart index 7e95c3999dd..a83209782ac 100644 --- a/lib/ui/page/home/introduction/view.dart +++ b/lib/ui/page/home/introduction/view.dart @@ -29,6 +29,7 @@ import '/themes.dart'; import '/ui/page/auth/widget/cupertino_button.dart'; import '/ui/page/home/widget/num.dart'; import '/ui/page/login/controller.dart'; +import '/ui/page/login/privacy_policy/view.dart'; import '/ui/page/login/terms_of_use/view.dart'; import '/ui/page/login/view.dart'; import '/ui/widget/download_button.dart'; @@ -108,6 +109,7 @@ class IntroductionView extends StatelessWidget { case IntroductionViewStage.oneTime: header = ModalPopupHeader( text: 'label_guest_account_created'.l10n, + close: false, ); children = [ @@ -140,6 +142,8 @@ class IntroductionView extends StatelessWidget { ), ), const SizedBox(height: 25), + _terms(context), + const SizedBox(height: 25 / 2), OutlinedRoundedButton( key: const Key('SetPasswordButton'), maxWidth: double.infinity, @@ -150,13 +154,6 @@ class IntroductionView extends StatelessWidget { style: style.fonts.normal.regular.onPrimary, ), ), - const SizedBox(height: 25 / 2), - Center( - child: StyledCupertinoButton( - label: 'btn_terms_and_conditions'.l10n, - onPressed: () => TermsOfUseView.show(context), - ), - ), const SizedBox(height: 8), ]; break; @@ -369,4 +366,40 @@ class IntroductionView extends StatelessWidget { ), ); } + + /// Builds the legal disclaimer information. + Widget _terms(BuildContext context) { + final style = Theme.of(context).style; + + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'alert_by_proceeding_you_accept_terms1'.l10n, + style: style.fonts.small.regular.secondary, + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms2'.l10n, + style: style.fonts.small.regular.primary, + recognizer: TapGestureRecognizer() + ..onTap = () => TermsOfUseView.show(context), + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms3'.l10n, + style: style.fonts.small.regular.secondary, + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms4'.l10n, + style: style.fonts.small.regular.primary, + recognizer: TapGestureRecognizer() + ..onTap = () => PrivacyPolicy.show(context), + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms5'.l10n, + style: style.fonts.small.regular.secondary, + ), + ], + ), + ); + } } diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index 34ac77547a9..ec52ebf9bbd 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -16,12 +16,12 @@ // . import 'package:animated_size_and_fade/animated_size_and_fade.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; -import '/ui/page/auth/widget/cupertino_button.dart'; import '/ui/page/home/page/chat/widget/chat_item.dart'; import '/ui/widget/modal_popup.dart'; import '/ui/widget/outlined_rounded_button.dart'; @@ -29,6 +29,7 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/ui/widget/widget_button.dart'; import 'controller.dart'; +import 'privacy_policy/view.dart'; import 'terms_of_use/view.dart'; import 'widget/primary_button.dart'; import 'widget/sign_button.dart'; @@ -306,12 +307,7 @@ class LoginView extends StatelessWidget { c.stage.value = LoginViewStage.signUpWithEmail, ), const SizedBox(height: 16), - Center( - child: StyledCupertinoButton( - label: 'btn_terms_and_conditions'.l10n, - onPressed: () => TermsOfUseView.show(context), - ), - ), + _terms(context), ]; break; @@ -392,7 +388,8 @@ class LoginView extends StatelessWidget { icon: const SvgIcon(SvgIcons.password), padding: const EdgeInsets.only(left: 1), ), - const SizedBox(height: 25 / 2), + const SizedBox(height: 16), + _terms(context), ]; break; @@ -440,4 +437,40 @@ class LoginView extends StatelessWidget { }, ); } + + /// Builds the legal disclaimer information. + Widget _terms(BuildContext context) { + final style = Theme.of(context).style; + + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'alert_by_proceeding_you_accept_terms1'.l10n, + style: style.fonts.small.regular.secondary, + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms2'.l10n, + style: style.fonts.small.regular.primary, + recognizer: TapGestureRecognizer() + ..onTap = () => TermsOfUseView.show(context), + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms3'.l10n, + style: style.fonts.small.regular.secondary, + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms4'.l10n, + style: style.fonts.small.regular.primary, + recognizer: TapGestureRecognizer() + ..onTap = () => PrivacyPolicy.show(context), + ), + TextSpan( + text: 'alert_by_proceeding_you_accept_terms5'.l10n, + style: style.fonts.small.regular.secondary, + ), + ], + ), + ); + } } diff --git a/web/privacy.html b/web/privacy.html new file mode 100644 index 00000000000..84a948637b8 --- /dev/null +++ b/web/privacy.html @@ -0,0 +1,124 @@ + + + + + + + + + Privacy Policy + + + + + Privacy Policy +

This privacy policy applies to the Gapopa app (hereby referred to as "Application") for mobile devices that was + created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. This + service is intended for use "AS IS".


Information Collection and Use +

The Application collects information when you download and use it. This information may include information such as +

+
    +
  • Your device's Internet Protocol address (e.g. IP address)
  • +
  • The pages of the Application that you visit, the time and date of your visit, the time spent on those pages
  • +
  • The time spent on the Application
  • +
  • The operating system you use on your mobile device
  • +
+


+

The Application does not gather precise information about the location of your mobile device.

+
+

The Application collects your device's location, which helps the Service Provider determine your approximate + geographical location and make use of in below ways:

+
    +
  • Geolocation Services: The Service Provider utilizes location data to provide features such as personalized + content, relevant recommendations, and location-based services.
  • +
  • Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user + behavior, identify trends, and improve the overall performance and functionality of the Application.
  • +
  • Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external + services. These services assist them in enhancing the Application and optimizing their offerings.
  • +
+

+

The Service Provider may use the information you provided to contact you from time to time to provide you with + important information, required notices and marketing promotions.


+

For a better experience, while using the Application, the Service Provider may require you to provide us with + certain personally identifiable information, including but not limited to Email, phone number. The information that + the Service Provider request will be retained by them and used as described in this privacy policy.

+
Third Party Access +

Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in + improving the Application and their service. The Service Provider may share your information with third parties in + the ways that are described in this privacy statement.

+

+

Please note that the Application utilizes third-party services that have their own Privacy Policy about handling + data. Below are the links to the Privacy Policy of the third-party service providers used by the Application:

+ +

+

The Service Provider may disclose User Provided and Automatically Collected Information:

+
    +
  • as required by law, such as to comply with a subpoena, or similar legal process;
  • +
  • when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the + safety of others, investigate fraud, or respond to a government request;
  • +
  • with their trusted services providers who work on their behalf, do not have an independent use of the + information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement.
  • +
+


Opt-Out Rights +

You can stop all collection of information by the Application easily by uninstalling it. You may use the standard + uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or + network.


Data Retention Policy +

The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable + time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please + proceed to the https://gapopa.com/erase for instructions on how to do so.

+
Children +

The Service Provider does not use the Application to knowingly solicit data from or market to children under the + age of 13.

+

+

The Application does not address anyone under the age of 13. The Service Provider does not knowingly collect + personally identifiable information from children under 13 years of age. In the case the Service Provider discover + that a child under 13 has provided personal information, the Service Provider will immediately delete this from + their servers. If you are a parent or guardian and you are aware that your child has provided us with personal + information, please contact the Service Provider (admin@gapopa.com) so that they will be able to take the + necessary actions.

+

Security +

The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider + provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and + maintains.


Changes +

This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any + changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this + Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.


+

This privacy policy is effective as of 2024-04-11


Your Consent +

By using the Application, you are consenting to the processing of your information as set forth in this Privacy + Policy now and as amended by us.


Contact Us +

If you have any questions regarding privacy while using the Application, or have questions about the practices, + please contact the Service Provider via email at admin@gapopa.com.

+
+

This privacy policy page was generated by App Privacy Policy Generator

+ + + \ No newline at end of file diff --git a/web/terms.html b/web/terms.html new file mode 100644 index 00000000000..1cdd3bb26d9 --- /dev/null +++ b/web/terms.html @@ -0,0 +1,82 @@ + + + + + + + Terms & Conditions + + + + + Terms & Conditions
+

These terms and conditions applies to the Gapopa app (hereby referred to as "Application") for mobile devices that + was created by IT ENGINEERING MANAGEMENT INC (hereby referred to as "Service Provider") as an Open Source service. +


+

Upon downloading or utilizing the Application, you are automatically agreeing to the following terms. It is + strongly advised that you thoroughly read and understand these terms prior to using the Application.


+

The Service Provider is dedicated to ensuring that the Application is as beneficial and efficient as possible. As + such, they reserve the right to modify the Application or charge for their services at any time and for any reason. + The Service Provider assures you that any charges for the Application or its services will be clearly communicated + to you.


+

The Application stores and processes personal data that you have provided to the Service Provider in order to + provide the Service. It is your responsibility to maintain the security of your phone and access to the Application. + The Service Provider strongly advise against jailbreaking or rooting your phone, which involves removing software + restrictions and limitations imposed by the official operating system of your device. Such actions could expose your + phone to malware, viruses, malicious programs, compromise your phone's security features, and may result in the + Application not functioning correctly or at all.

+
+

Please note that the Application utilizes third-party services that have their own Terms and Conditions. Below + are the links to the Terms and Conditions of the third-party service providers used by the Application:

+ +

+

Please be aware that the Service Provider does not assume responsibility for certain aspects. Some functions of the + Application require an active internet connection, which can be Wi-Fi or provided by your mobile network provider. + The Service Provider cannot be held responsible if the Application does not function at full capacity due to lack of + access to Wi-Fi or if you have exhausted your data allowance.


+

If you are using the application outside of a Wi-Fi area, please be aware that your mobile network provider's + agreement terms still apply. Consequently, you may incur charges from your mobile provider for data usage during the + connection to the application, or other third-party charges. By using the application, you accept responsibility for + any such charges, including roaming data charges if you use the application outside of your home territory (i.e., + region or country) without disabling data roaming. If you are not the bill payer for the device on which you are + using the application, they assume that you have obtained permission from the bill payer.


+

Similarly, the Service Provider cannot always assume responsibility for your usage of the application. For + instance, it is your responsibility to ensure that your device remains charged. If your device runs out of battery + and you are unable to access the Service, the Service Provider cannot be held responsible.


+

In terms of the Service Provider's responsibility for your use of the application, it is important to note that + while they strive to ensure that it is updated and accurate at all times, they do rely on third parties to provide + information to them so that they can make it available to you. The Service Provider accepts no liability for any + loss, direct or indirect, that you experience as a result of relying entirely on this functionality of the + application.


+

The Service Provider may wish to update the application at some point. The application is currently available as + per the requirements for the operating system (and for any additional systems they decide to extend the availability + of the application to) may change, and you will need to download the updates if you want to continue using the + application. The Service Provider does not guarantee that it will always update the application so that it is + relevant to you and/or compatible with the particular operating system version installed on your device. However, + you agree to always accept updates to the application when offered to you. The Service Provider may also wish to + cease providing the application and may terminate its use at any time without providing termination notice to you. + Unless they inform you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms + will end; (b) you must cease using the application, and (if necessary) delete it from your device.

+
Changes to These Terms and Conditions +

The Service Provider may periodically update their Terms and Conditions. Therefore, you are advised to review this + page regularly for any changes. The Service Provider will notify you of any changes by posting the new Terms and + Conditions on this page.


+

These terms and conditions are effective as of 2024-04-11


Contact Us +

If you have any questions or suggestions about the Terms and Conditions, please do not hesitate to contact the + Service Provider at admin@gapopa.com.

+
+

This Terms and Conditions page was generated by App Privacy Policy Generator

+ + + \ No newline at end of file From 82d952d127a834e9419bd84bffe57e7706c938ce Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 17 Apr 2024 18:20:19 +0300 Subject: [PATCH 26/88] Fix CI --- .github/workflows/ci.yml | 832 +++++++++++++++++++-------------------- 1 file changed, 416 insertions(+), 416 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c87a317da0c..995ec88059a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["main"] + branches: ["main", "stable-design"] tags: ["helm/**", "v*"] pull_request: branches: ["main"] @@ -23,18 +23,18 @@ jobs: pr: if: ${{ github.event_name == 'pull_request' }} needs: - - appcast-edge + # - appcast-edge - build - build-linux - copyright - - dartanalyze - - dartdoc - - dartfmt + # - dartanalyze + # - dartdoc + # - dartfmt - docker - - helm-lint - - pubspec - - test-e2e - - test-unit + # - helm-lint + # - pubspec + # - test-e2e + # - test-unit runs-on: ubuntu-latest steps: - run: true @@ -53,60 +53,60 @@ jobs: - run: make copyright check=yes - dartanalyze: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # dartanalyze: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - run: make flutter.pub + # - run: make flutter.pub - - run: make flutter.analyze + # - run: make flutter.analyze - dartfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # dartfmt: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - run: make flutter.fmt check=yes + # - run: make flutter.fmt check=yes - helm-lint: - name: helm lint - if: ${{ !startsWith(github.ref, 'refs/tags/v') }} - strategy: - fail-fast: false - matrix: - chart: ["messenger"] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: azure/setup-helm@v4 + # helm-lint: + # name: helm lint + # if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + # strategy: + # fail-fast: false + # matrix: + # chart: ["messenger"] + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: azure/setup-helm@v4 - - run: make helm.lint chart=${{ matrix.chart }} + # - run: make helm.lint chart=${{ matrix.chart }} - pubspec: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # pubspec: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - run: make flutter.pub + # - run: make flutter.pub - - name: Check `pubspec.lock` is in sync with `pubspec.yaml` - run: git diff --exit-code + # - name: Check `pubspec.lock` is in sync with `pubspec.yaml` + # run: git diff --exit-code @@ -115,30 +115,30 @@ jobs: # Building # ############ - appcast-edge: - name: appcast (edge) - # Despite this CI job is not always needed, we intentionally keep it running - # always, because we want the `docker` CI job to depend on it, and GitHub - # Actions doesn't provide any conditional depending at the moment. - #if: ${{ github.ref == 'refs/heads/main' - # || github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # for correct versioning via `git describe --tags` + # appcast-edge: + # name: appcast (edge) + # # Despite this CI job is not always needed, we intentionally keep it running + # # always, because we want the `docker` CI job to depend on it, and GitHub + # # Actions doesn't provide any conditional depending at the moment. + # #if: ${{ github.ref == 'refs/heads/main' + # # || github.event_name == 'pull_request' }} + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # with: + # fetch-depth: 0 # for correct versioning via `git describe --tags` - - run: make appcast.xml - env: - link: ${{ secrets.ARTIFACTS_MAIN }} - notes: ${{ secrets.APPCAST_NOTES }} + # - run: make appcast.xml + # env: + # link: ${{ secrets.ARTIFACTS_MAIN }} + # notes: ${{ secrets.APPCAST_NOTES }} - - uses: actions/upload-artifact@v4 - with: - name: appcast-edge-${{ github.run_number }} - path: appcast.xml - if-no-files-found: error - retention-days: 1 + # - uses: actions/upload-artifact@v4 + # with: + # name: appcast-edge-${{ github.run_number }} + # path: appcast.xml + # if-no-files-found: error + # retention-days: 1 build: strategy: @@ -402,30 +402,30 @@ jobs: if-no-files-found: error retention-days: 1 - dartdoc: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # dartdoc: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - run: make flutter.pub + # - run: make flutter.pub - - run: make docs.dart clean=yes + # - run: make docs.dart clean=yes - - uses: actions/upload-artifact@v4 - with: - name: dartdoc-${{ github.run_number }} - path: doc/api/ - if-no-files-found: error - if: ${{ github.ref == 'refs/heads/main' - || startsWith(github.ref, 'refs/tags/v') }} + # - uses: actions/upload-artifact@v4 + # with: + # name: dartdoc-${{ github.run_number }} + # path: doc/api/ + # if-no-files-found: error + # if: ${{ github.ref == 'refs/heads/main' + # || startsWith(github.ref, 'refs/tags/v') }} docker: - needs: ["appcast-edge", "build", "build-linux"] + needs: ["build", "build-linux"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -488,98 +488,98 @@ jobs: # Testing # ########### - test-e2e: - name: test (E2E) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: ${{ (github.event_name == 'pull_request') && 2 || 1 }} - - name: Retrieve commit messages - id: commit - run: | - echo "messages=$(git log --grep='\[debug\]' \ - --grep='\[trace\]' \ - --format=%s)" \ - >> $GITHUB_OUTPUT + # test-e2e: + # name: test (E2E) + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # with: + # fetch-depth: ${{ (github.event_name == 'pull_request') && 2 || 1 }} + # - name: Retrieve commit messages + # id: commit + # run: | + # echo "messages=$(git log --grep='\[debug\]' \ + # --grep='\[trace\]' \ + # --format=%s)" \ + # >> $GITHUB_OUTPUT - - name: Determine log level - id: log - run: | - echo "level=${{ (contains(steps.commit.outputs.messages, '[debug]') - && 'debug') - || (contains(steps.commit.outputs.messages, '[trace]') - && 'trace') - || 'info' }}" - >> $GITHUB_OUTPUT + # - name: Determine log level + # id: log + # run: | + # echo "level=${{ (contains(steps.commit.outputs.messages, '[debug]') + # && 'debug') + # || (contains(steps.commit.outputs.messages, '[trace]') + # && 'trace') + # || 'info' }}" + # >> $GITHUB_OUTPUT - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - name: Login to private E2E container registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.E2E_REGISTRY }} - username: ${{ secrets.E2E_USER }} - password: ${{ secrets.E2E_PASS }} + # - name: Login to private E2E container registry + # uses: docker/login-action@v3 + # with: + # registry: ${{ secrets.E2E_REGISTRY }} + # username: ${{ secrets.E2E_USER }} + # password: ${{ secrets.E2E_PASS }} - - run: make flutter.pub + # - run: make flutter.pub - - name: Prepare E2E environment - uses: SpicyPizza/create-envfile@v2 - with: - envkey_COMPOSE_BACKEND: ${{ secrets.E2E_BACKEND }} - envkey_COMPOSE_COCKROACHDB: ${{ secrets.E2E_COCKROACH }} - envkey_COMPOSE_FCM_SA_KEY: ${{ secrets.E2E_FCM_SA_KEY }} - envkey_COMPOSE_FRONTEND_IMAGE: nginx - envkey_COMPOSE_FRONTEND_TAG: stable-alpine - envkey_COMPOSE_FILESERVER: ${{ secrets.E2E_FILESERVER }} - envkey_COMPOSE_PROJECT_NAME: messenger - fail_on_empty: true - - - run: mkdir build/ - - # Run `chromedriver` and `make test.e2e` simultaneously, as `web-server` - # device doesn't produce any progression logs, yet `--enable-chrome-logs` - # passed to the `chromedriver` does. - # - # Use `grep` to remove the meaningless logs from the output. - - uses: nanasess/setup-chromedriver@v2 - - run: | - chromedriver --port=4444 --enable-chrome-logs \ - --disable-dev-shm-usage \ - 2> >(grep -v -E '${{ join(fromJson(env.OMIT), '|') }}') & - make test.e2e start-app=yes device=web-server pull=yes no-cache=yes \ - dart-env='SOCAPP_LOG_LEVEL=${{ steps.log.outputs.level }}' - env: - OMIT: | - [ - ":INFO:CONSOLE\\(60603\\)] ", - "\"Got object store box in database", - "\\[CONFIG\\]: Remote configuration fetch failed" - ] - - # Grant permissions to `.cache/` dir created in the previous step, since - # `hashFile` may fail: https://github.com/actions/runner/issues/449 - - run: sudo chmod -R 755 .cache/ - - test-unit: - name: test (unit) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VER }} - channel: stable - cache: true + # - name: Prepare E2E environment + # uses: SpicyPizza/create-envfile@v2 + # with: + # envkey_COMPOSE_BACKEND: ${{ secrets.E2E_BACKEND }} + # envkey_COMPOSE_COCKROACHDB: ${{ secrets.E2E_COCKROACH }} + # envkey_COMPOSE_FCM_SA_KEY: ${{ secrets.E2E_FCM_SA_KEY }} + # envkey_COMPOSE_FRONTEND_IMAGE: nginx + # envkey_COMPOSE_FRONTEND_TAG: stable-alpine + # envkey_COMPOSE_FILESERVER: ${{ secrets.E2E_FILESERVER }} + # envkey_COMPOSE_PROJECT_NAME: messenger + # fail_on_empty: true + + # - run: mkdir build/ + + # # Run `chromedriver` and `make test.e2e` simultaneously, as `web-server` + # # device doesn't produce any progression logs, yet `--enable-chrome-logs` + # # passed to the `chromedriver` does. + # # + # # Use `grep` to remove the meaningless logs from the output. + # - uses: nanasess/setup-chromedriver@v2 + # - run: | + # chromedriver --port=4444 --enable-chrome-logs \ + # --disable-dev-shm-usage \ + # 2> >(grep -v -E '${{ join(fromJson(env.OMIT), '|') }}') & + # make test.e2e start-app=yes device=web-server pull=yes no-cache=yes \ + # dart-env='SOCAPP_LOG_LEVEL=${{ steps.log.outputs.level }}' + # env: + # OMIT: | + # [ + # ":INFO:CONSOLE\\(60603\\)] ", + # "\"Got object store box in database", + # "\\[CONFIG\\]: Remote configuration fetch failed" + # ] + + # # Grant permissions to `.cache/` dir created in the previous step, since + # # `hashFile` may fail: https://github.com/actions/runner/issues/449 + # - run: sudo chmod -R 755 .cache/ + + # test-unit: + # name: test (unit) + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: subosito/flutter-action@v2 + # with: + # flutter-version: ${{ env.FLUTTER_VER }} + # channel: stable + # cache: true - - run: make flutter.pub + # - run: make flutter.pub - - run: make test.unit + # - run: make test.unit @@ -647,205 +647,205 @@ jobs: image=review if: ${{ steps.skip.outputs.no == 'true' }} - helm-push: - name: helm push - if: ${{ startsWith(github.ref, 'refs/tags/helm/') }} - needs: ["helm-lint"] - strategy: - max-parallel: 1 - matrix: - chart: ["messenger"] - runs-on: ubuntu-latest - steps: - - id: skip - run: echo ::set-output name=no::${{ - startsWith(github.ref, - format('refs/tags/helm/{0}/', matrix.chart)) - }} - - - uses: actions/checkout@v4 - if: ${{ steps.skip.outputs.no == 'true' }} - - uses: azure/setup-helm@v4 - if: ${{ steps.skip.outputs.no == 'true' }} - - - name: Parse semver versions from Git tag - id: semver - uses: actions-ecosystem/action-regex-match@v2 - with: - text: ${{ github.ref }} - regex: '^refs/tags/helm/${{ matrix.chart }}/((([0-9]+)\.[0-9]+)\.[0-9]+(-.+)?)$' - if: ${{ steps.skip.outputs.no == 'true' }} - - name: Verify Git tag version matches `Chart.yaml` version - run: | - test "${{ steps.semver.outputs.group1 }}" \ - == "$(grep -m1 'version: ' helm/${{ matrix.chart }}/Chart.yaml \ - | cut -d' ' -f2)" - if: ${{ steps.skip.outputs.no == 'true' }} - - - run: make helm.package chart=${{ matrix.chart }} - out-dir=.cache/helm/ clean=yes - if: ${{ steps.skip.outputs.no == 'true' }} - - # Helm's digest is just SHA256 checksum: - # https://github.com/helm/helm/blob/v3.9.2/pkg/provenance/sign.go#L417-L418 - - name: Generate SHA256 checksum - run: ls -1 | xargs -I {} sh -c "sha256sum {} > {}.sha256sum" - working-directory: .cache/helm/ - if: ${{ steps.skip.outputs.no == 'true' }} - - name: Show generated SHA256 checksum - run: cat *.sha256sum - working-directory: .cache/helm/ - if: ${{ steps.skip.outputs.no == 'true' }} - - - name: Parse CHANGELOG link - id: changelog - run: echo ::set-output - name=link::${{ github.server_url }}/${{ github.repository }}/blob/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}/CHANGELOG.md#$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/{s/^## \[\(.*\)\][^0-9]*\([0-9].*\)/\1--\2/;s/[^0-9a-z-]*//g;p;}' helm/${{ matrix.chart }}/CHANGELOG.md) - - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - name: helm/${{ matrix.chart }} ${{ steps.semver.outputs.group1 }} - body: > - [Changelog](${{ steps.changelog.outputs.link }}) | - [Overview](${{ github.server_url }}/${{ github.repository }}/tree/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}) | - [Values](${{ github.server_url }}/${{ github.repository }}/blob/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}/values.yaml) - files: | - .cache/helm/*.tgz - .cache/helm/*.sha256sum - fail_on_unmatched_files: true - prerelease: ${{ contains(steps.semver.outputs.group1, '-') }} - if: ${{ steps.skip.outputs.no == 'true' }} - - - name: Parse Git repository name - id: repo - uses: actions-ecosystem/action-regex-match@v2 - with: - text: ${{ github.repository }} - regex: '^${{ github.repository_owner }}/(.+)$' + # helm-push: + # name: helm push + # if: ${{ startsWith(github.ref, 'refs/tags/helm/') }} + # needs: ["helm-lint"] + # strategy: + # max-parallel: 1 + # matrix: + # chart: ["messenger"] + # runs-on: ubuntu-latest + # steps: + # - id: skip + # run: echo ::set-output name=no::${{ + # startsWith(github.ref, + # format('refs/tags/helm/{0}/', matrix.chart)) + # }} - # TODO: Find or write a tool to build index idempotently from GitHub - # releases, and keep on GitHub Pages only the built index. - # https://github.com/helm/chart-releaser/issues/133 - - name: Update Helm repository index - run: | - set -ex + # - uses: actions/checkout@v4 + # if: ${{ steps.skip.outputs.no == 'true' }} + # - uses: azure/setup-helm@v4 + # if: ${{ steps.skip.outputs.no == 'true' }} - git config --local user.email 'github-actions[bot]@users.noreply.github.com' - git config --local user.name 'github-actions[bot]' + # - name: Parse semver versions from Git tag + # id: semver + # uses: actions-ecosystem/action-regex-match@v2 + # with: + # text: ${{ github.ref }} + # regex: '^refs/tags/helm/${{ matrix.chart }}/((([0-9]+)\.[0-9]+)\.[0-9]+(-.+)?)$' + # if: ${{ steps.skip.outputs.no == 'true' }} + # - name: Verify Git tag version matches `Chart.yaml` version + # run: | + # test "${{ steps.semver.outputs.group1 }}" \ + # == "$(grep -m1 'version: ' helm/${{ matrix.chart }}/Chart.yaml \ + # | cut -d' ' -f2)" + # if: ${{ steps.skip.outputs.no == 'true' }} + + # - run: make helm.package chart=${{ matrix.chart }} + # out-dir=.cache/helm/ clean=yes + # if: ${{ steps.skip.outputs.no == 'true' }} + + # # Helm's digest is just SHA256 checksum: + # # https://github.com/helm/helm/blob/v3.9.2/pkg/provenance/sign.go#L417-L418 + # - name: Generate SHA256 checksum + # run: ls -1 | xargs -I {} sh -c "sha256sum {} > {}.sha256sum" + # working-directory: .cache/helm/ + # if: ${{ steps.skip.outputs.no == 'true' }} + # - name: Show generated SHA256 checksum + # run: cat *.sha256sum + # working-directory: .cache/helm/ + # if: ${{ steps.skip.outputs.no == 'true' }} + + # - name: Parse CHANGELOG link + # id: changelog + # run: echo ::set-output + # name=link::${{ github.server_url }}/${{ github.repository }}/blob/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}/CHANGELOG.md#$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/{s/^## \[\(.*\)\][^0-9]*\([0-9].*\)/\1--\2/;s/[^0-9a-z-]*//g;p;}' helm/${{ matrix.chart }}/CHANGELOG.md) + + # - name: Create GitHub release + # uses: softprops/action-gh-release@v2 + # with: + # name: helm/${{ matrix.chart }} ${{ steps.semver.outputs.group1 }} + # body: > + # [Changelog](${{ steps.changelog.outputs.link }}) | + # [Overview](${{ github.server_url }}/${{ github.repository }}/tree/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}) | + # [Values](${{ github.server_url }}/${{ github.repository }}/blob/helm%2F${{ matrix.chart }}%2F${{ steps.semver.outputs.group1 }}/helm/${{ matrix.chart }}/values.yaml) + # files: | + # .cache/helm/*.tgz + # .cache/helm/*.sha256sum + # fail_on_unmatched_files: true + # prerelease: ${{ contains(steps.semver.outputs.group1, '-') }} + # if: ${{ steps.skip.outputs.no == 'true' }} + + # - name: Parse Git repository name + # id: repo + # uses: actions-ecosystem/action-regex-match@v2 + # with: + # text: ${{ github.repository }} + # regex: '^${{ github.repository_owner }}/(.+)$' - git fetch origin gh-pages:gh-pages - git checkout gh-pages - git reset --hard + # # TODO: Find or write a tool to build index idempotently from GitHub + # # releases, and keep on GitHub Pages only the built index. + # # https://github.com/helm/chart-releaser/issues/133 + # - name: Update Helm repository index + # run: | + # set -ex - mkdir -p helm/ - cp -rf .cache/helm/*.tgz helm/ - helm repo index helm/ --url=https://${{ github.repository_owner }}.github.io/${{ steps.repo.outputs.group1 }}/helm + # git config --local user.email 'github-actions[bot]@users.noreply.github.com' + # git config --local user.name 'github-actions[bot]' - git checkout --orphan orphan-gh-pages - git add --all - git commit -m 'Release ${{ steps.semver.outputs.group1 }} version of `${{ matrix.chart }}` Helm chart' - git branch -M orphan-gh-pages gh-pages - git push --force origin gh-pages - if: ${{ steps.skip.outputs.no == 'true' }} + # git fetch origin gh-pages:gh-pages + # git checkout gh-pages + # git reset --hard - release-github: - name: release (GitHub) - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - needs: - - build - - build-linux - - copyright - - dartanalyze - - dartdoc - - dartfmt - - docker-push - - test-e2e - - test-unit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + # mkdir -p helm/ + # cp -rf .cache/helm/*.tgz helm/ + # helm repo index helm/ --url=https://${{ github.repository_owner }}.github.io/${{ steps.repo.outputs.group1 }}/helm - - name: Parse semver versions from Git tag - id: semver - uses: actions-ecosystem/action-regex-match@v2 - with: - text: ${{ github.ref }} - regex: '^refs/tags/v(((([0-9]+)\.[0-9]+)\.[0-9]+)(-.+)?)$' - - name: Verify Git tag version matches `pubspec.yaml` version - run: | - test "${{ steps.semver.outputs.group1 }}" \ - == "$(grep -m1 'version: ' pubspec.yaml | cut -d ' ' -f2)" - - - name: Parse CHANGELOG link - id: changelog - run: echo ::set-output - name=link::${{ github.server_url }}/${{ github.repository }}/blob/v${{ steps.semver.outputs.group1 }}/CHANGELOG.md#$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/{s/^## \[\(.*\)\][^0-9]*\([0-9].*\)/\1--\2/;s/[^0-9a-z-]*//g;p;}' CHANGELOG.md) - - name: Parse milestone link - id: milestone - run: echo ::set-output - name=link::${{ github.server_url }}/${{ github.repository }}/milestone/$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/,/Milestone/{s/.*milestone.\([0-9]*\).*/\1/p;}' CHANGELOG.md) + # git checkout --orphan orphan-gh-pages + # git add --all + # git commit -m 'Release ${{ steps.semver.outputs.group1 }} version of `${{ matrix.chart }}` Helm chart' + # git branch -M orphan-gh-pages gh-pages + # git push --force origin gh-pages + # if: ${{ steps.skip.outputs.no == 'true' }} - - uses: actions/download-artifact@v4 - with: - name: build-apk-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-appbundle-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-ios-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-linux-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-macos-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-web-${{ github.run_number }} - path: artifacts/ - - uses: actions/download-artifact@v4 - with: - name: build-windows-${{ github.run_number }} - path: artifacts/ + # release-github: + # name: release (GitHub) + # if: ${{ startsWith(github.ref, 'refs/tags/v') }} + # needs: + # - build + # - build-linux + # - copyright + # - dartanalyze + # - dartdoc + # - dartfmt + # - docker-push + # - test-e2e + # - test-unit + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: dartdoc-${{ github.run_number }} - path: .cache/dartdoc/ - - uses: thedoctor0/zip-release@0.7.6 - with: - custom: --symlinks # preserve symlinks, instead of copying files - filename: artifacts/docs.zip - path: .cache/dartdoc/ - - name: Generate dartdoc SHA256 checksum - run: sh -c "sha256sum docs.zip > docs.zip.sha256sum" - working-directory: artifacts/ + # - name: Parse semver versions from Git tag + # id: semver + # uses: actions-ecosystem/action-regex-match@v2 + # with: + # text: ${{ github.ref }} + # regex: '^refs/tags/v(((([0-9]+)\.[0-9]+)\.[0-9]+)(-.+)?)$' + # - name: Verify Git tag version matches `pubspec.yaml` version + # run: | + # test "${{ steps.semver.outputs.group1 }}" \ + # == "$(grep -m1 'version: ' pubspec.yaml | cut -d ' ' -f2)" + + # - name: Parse CHANGELOG link + # id: changelog + # run: echo ::set-output + # name=link::${{ github.server_url }}/${{ github.repository }}/blob/v${{ steps.semver.outputs.group1 }}/CHANGELOG.md#$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/{s/^## \[\(.*\)\][^0-9]*\([0-9].*\)/\1--\2/;s/[^0-9a-z-]*//g;p;}' CHANGELOG.md) + # - name: Parse milestone link + # id: milestone + # run: echo ::set-output + # name=link::${{ github.server_url }}/${{ github.repository }}/milestone/$(sed -n '/^## \[${{ steps.semver.outputs.group1 }}\]/,/Milestone/{s/.*milestone.\([0-9]*\).*/\1/p;}' CHANGELOG.md) - - name: Show artifacts SHA256 checksums - run: cat *.sha256sum - working-directory: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-apk-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-appbundle-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-ios-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-linux-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-macos-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-web-${{ github.run_number }} + # path: artifacts/ + # - uses: actions/download-artifact@v4 + # with: + # name: build-windows-${{ github.run_number }} + # path: artifacts/ - - name: Create GitHub release - uses: softprops/action-gh-release@v2 - with: - name: ${{ steps.semver.outputs.group1 }} - body: > - [Changelog](${{ steps.changelog.outputs.link }}) | - [Milestone](${{ steps.milestone.outputs.link }}) - files: | - artifacts/*.apk - artifacts/*.aab - artifacts/*.zip - artifacts/*.sha256sum - fail_on_unmatched_files: true - prerelease: ${{ contains(steps.semver.outputs.group1, '-') }} + # - uses: actions/download-artifact@v4 + # with: + # name: dartdoc-${{ github.run_number }} + # path: .cache/dartdoc/ + # - uses: thedoctor0/zip-release@0.7.6 + # with: + # custom: --symlinks # preserve symlinks, instead of copying files + # filename: artifacts/docs.zip + # path: .cache/dartdoc/ + # - name: Generate dartdoc SHA256 checksum + # run: sh -c "sha256sum docs.zip > docs.zip.sha256sum" + # working-directory: artifacts/ + + # - name: Show artifacts SHA256 checksums + # run: cat *.sha256sum + # working-directory: artifacts/ + + # - name: Create GitHub release + # uses: softprops/action-gh-release@v2 + # with: + # name: ${{ steps.semver.outputs.group1 }} + # body: > + # [Changelog](${{ steps.changelog.outputs.link }}) | + # [Milestone](${{ steps.milestone.outputs.link }}) + # files: | + # artifacts/*.apk + # artifacts/*.aab + # artifacts/*.zip + # artifacts/*.sha256sum + # fail_on_unmatched_files: true + # prerelease: ${{ contains(steps.semver.outputs.group1, '-') }} @@ -854,33 +854,33 @@ jobs: # Deploying # ############# - deploy-docs: - name: deploy (dartdoc) - if: ${{ github.ref == 'refs/heads/main' - || startsWith(github.ref, 'refs/tags/v') }} - needs: ["dartdoc"] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: gh-pages + # deploy-docs: + # name: deploy (dartdoc) + # if: ${{ github.ref == 'refs/heads/main' + # || startsWith(github.ref, 'refs/tags/v') }} + # needs: ["dartdoc"] + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # with: + # ref: gh-pages - - run: rm -rf ${{ (github.ref_name == 'main' && 'main') || 'release' }} + # - run: rm -rf ${{ (github.ref_name == 'main' && 'main') || 'release' }} - - uses: actions/download-artifact@v4 - with: - name: dartdoc-${{ github.run_number }} - path: ${{ (github.ref_name == 'main' && 'main') || 'release' }} + # - uses: actions/download-artifact@v4 + # with: + # name: dartdoc-${{ github.run_number }} + # path: ${{ (github.ref_name == 'main' && 'main') || 'release' }} - - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: . - commit_message: ${{ github.event.head_commit.message }} - force_orphan: true - keep_files: true - user_name: github-actions[bot] - user_email: github-actions[bot]@users.noreply.github.com + # - uses: peaceiris/actions-gh-pages@v4 + # with: + # github_token: ${{ secrets.GITHUB_TOKEN }} + # publish_dir: . + # commit_message: ${{ github.event.head_commit.message }} + # force_orphan: true + # keep_files: true + # user_name: github-actions[bot] + # user_email: github-actions[bot]@users.noreply.github.com # TODO: Uncomment, when self-hosted Sentry supports debug symbols. # deploy-sentry: @@ -942,33 +942,33 @@ jobs: # release=${{ steps.semver.outputs.group1 }} # url=${{ secrets.SENTRY_URL }} - deploy-staging: - name: deploy (staging) - if: ${{ github.ref == 'refs/heads/main' }} - needs: ["docker-push", "helm-lint"] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + # deploy-staging: + # name: deploy (staging) + # if: ${{ github.ref == 'refs/heads/main' }} + # needs: ["docker-push", "helm-lint"] + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 - - uses: azure/setup-kubectl@v4 - - name: Login to Kubernetes cluster - run: | - set -ex + # - uses: azure/setup-kubectl@v4 + # - name: Login to Kubernetes cluster + # run: | + # set -ex - kubectl config set-cluster ${{ secrets.K8S_CLUSTER }} --server=${{ secrets.K8S_API }} --insecure-skip-tls-verify=true - kubectl config set-credentials ${{ secrets.K8S_USER }} --token ${{ secrets.K8S_TOKEN }} - kubectl config set-context ${{ secrets.K8S_CONTEXT }} --namespace=${{ secrets.K8S_NAMESPACE }} --cluster=${{ secrets.K8S_CLUSTER }} --user=${{ secrets.K8S_USER }} + # kubectl config set-cluster ${{ secrets.K8S_CLUSTER }} --server=${{ secrets.K8S_API }} --insecure-skip-tls-verify=true + # kubectl config set-credentials ${{ secrets.K8S_USER }} --token ${{ secrets.K8S_TOKEN }} + # kubectl config set-context ${{ secrets.K8S_CONTEXT }} --namespace=${{ secrets.K8S_NAMESPACE }} --cluster=${{ secrets.K8S_CLUSTER }} --user=${{ secrets.K8S_USER }} - - name: Prepare secret Helm values - run: echo "${{ secrets.K8S_VALUES }}" - >> my.${{ secrets.K8S_CLUSTER }}.vals.yaml + # - name: Prepare secret Helm values + # run: echo "${{ secrets.K8S_VALUES }}" + # >> my.${{ secrets.K8S_CLUSTER }}.vals.yaml - - run: make helm.up cluster=${{ secrets.K8S_CLUSTER }} + # - run: make helm.up cluster=${{ secrets.K8S_CLUSTER }} - - name: Cleanup secret Helm values - run: rm -rf my.${{ secrets.K8S_CLUSTER }}.vals.yaml - if: ${{ always() }} + # - name: Cleanup secret Helm values + # run: rm -rf my.${{ secrets.K8S_CLUSTER }}.vals.yaml + # if: ${{ always() }} - - name: Logout from Kubernetes cluster - run: rm -rf ~/.kube/config - if: ${{ always() }} + # - name: Logout from Kubernetes cluster + # run: rm -rf ~/.kube/config + # if: ${{ always() }} From 1fd2bd31740831e1c7ca7dd045df348b1cedc62e Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 18 Apr 2024 18:00:30 +0300 Subject: [PATCH 27/88] Improve `Auth` and `Login` views --- assets/l10n/en-US.ftl | 5 +- assets/l10n/ru-RU.ftl | 3 +- .../query/user/CheckUserLoginOccupied.graphql | 3 + lib/domain/repository/auth.dart | 2 + lib/domain/service/auth.dart | 3 + lib/provider/gql/components/user.dart | 17 +++ lib/store/auth.dart | 6 + lib/ui/page/auth/view.dart | 24 ++-- lib/ui/page/home/controller.dart | 5 +- lib/ui/page/login/controller.dart | 123 +++++++++++++++++- lib/ui/page/login/view.dart | 82 ++++++++++++ lib/ui/widget/outlined_rounded_button.dart | 2 +- 12 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index cd4c2c31088..c6a37053fde 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -216,6 +216,7 @@ btn_join_call = Join call btn_leave = Leave btn_leave_chat = Leave chat btn_leave_group = Leave group +btn_login_and_password = Login and password btn_logout = Logout btn_media_settings = Media settings btn_message_info = Message info @@ -320,7 +321,7 @@ err_email_occupied = Specified E-mail is linked to another account. Please, annu err_incorrect_chat_name = Incorrect name err_incorrect_email = Incorrect E-mail. err_incorrect_input = Incorrect input. -err_incorrect_login_input = Unique login should contain only letters of the latin alphabet, numbers and symbols "-", "." and "_". It must start with a letter or number and be at least 3 and max 20 characters long. +err_incorrect_login_input = Unique login should contain only letters of the latin alphabet, numbers and symbols "-" and "_". It must start with a letter or number and be at least 3 and max 20 characters long. err_incorrect_login_or_password = Invalid login or password err_incorrect_phone = Incorrect phone number. err_input_empty = Must not be empty. @@ -990,7 +991,7 @@ label_set_password = Set password label_settings = Settings label_show_sections = Show sections label_sign_in = Sign in -label_sign_in_input = Gapopa ID, login, E-mail or phone +label_sign_in_input = Gapopa ID, login, E-mail or phone number label_sign_in_with_password = Sign in with password label_sign_up = Sign up label_sign_up_code_email_sent = Verification code has been sent to the e-mail {$text} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index d9c4b238f3b..d6687b5f8da 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -216,6 +216,7 @@ btn_join_call = Присоединиться к звонку btn_leave = Выйти btn_leave_chat = Выйти из чата btn_leave_group = Покинуть группу +btn_login_and_password = Логин и пароль btn_logout = Выйти btn_media_settings = Настройки медиа btn_message_info = Информация о сообщении @@ -321,7 +322,7 @@ err_email_occupied = Указанный E-mail привязан к другом err_incorrect_chat_name = Некорректное имя err_incorrect_email = Некорректный E-mail. err_incorrect_input = Некорректный формат. -err_incorrect_login_input = Уникальный логин должен содержать только буквы латинского алфавита, цифры и символы "-", "." и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. +err_incorrect_login_input = Уникальный логин должен содержать только буквы латинского алфавита, цифры и символы "-" и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. err_incorrect_login_or_password = Неверный логин или пароль err_incorrect_phone = Некорректный номер телефона. err_input_empty = Поле не должно быть пустым. diff --git a/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql b/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql new file mode 100644 index 00000000000..33bd744dfd5 --- /dev/null +++ b/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql @@ -0,0 +1,3 @@ +query CheckUserLoginOccupied($login: UserLogin!) { + checkUserLoginOccupied(login: $login) +} diff --git a/lib/domain/repository/auth.dart b/lib/domain/repository/auth.dart index d582ff606d0..88d119ba04c 100644 --- a/lib/domain/repository/auth.dart +++ b/lib/domain/repository/auth.dart @@ -136,4 +136,6 @@ abstract class AbstractAuthRepository { /// Uses the specified [ChatDirectLink] by the authenticated [MyUser] creating /// a new [Chat]-dialog or joining an existing [Chat]-group. Future useChatDirectLink(ChatDirectLinkSlug slug); + + Future checkUserLoginOccupied(UserLogin login); } diff --git a/lib/domain/service/auth.dart b/lib/domain/service/auth.dart index abacab9753c..b167e19c98c 100644 --- a/lib/domain/service/auth.dart +++ b/lib/domain/service/auth.dart @@ -550,6 +550,9 @@ class AuthService extends GetxService { return await _authRepository.useChatDirectLink(slug); } + Future checkUserLoginOccupied(UserLogin login) => + _authRepository.checkUserLoginOccupied(login); + /// Sets authorized [status] to `isLoadingMore` (aka "partly authorized"). void _authorized(Credentials creds) { Log.debug('_authorized($creds)', '$runtimeType'); diff --git a/lib/provider/gql/components/user.dart b/lib/provider/gql/components/user.dart index c50033c3799..1a5cdf1245e 100644 --- a/lib/provider/gql/components/user.dart +++ b/lib/provider/gql/components/user.dart @@ -55,6 +55,23 @@ mixin UserGraphQlMixin { return GetMyUser$Query.fromJson(res.data!); } + Future checkUserLoginOccupied( + UserLogin login + ) async { + Log.debug('checkUserLoginOccupied($login)', '$runtimeType'); + + final variables = CheckUserLoginOccupiedArguments(login: login); + QueryResult res = await client.query( + QueryOptions( + operationName: 'CheckUserLoginOccupied', + document: CheckUserLoginOccupiedQuery(variables: variables).document, + variables: variables.toJson(), + ), + ); + return CheckUserLoginOccupied$Query.fromJson(res.data!) + .checkUserLoginOccupied; + } + /// Returns an [User] by its [id]. /// /// ### Authentication diff --git a/lib/store/auth.dart b/lib/store/auth.dart index cda4efe7487..db3e5bc125d 100644 --- a/lib/store/auth.dart +++ b/lib/store/auth.dart @@ -258,4 +258,10 @@ class AuthRepository implements AbstractAuthRepository { var response = await _graphQlProvider.useChatDirectLink(slug); return response.chat.id; } + + @override + Future checkUserLoginOccupied(UserLogin login) async { + Log.debug('checkUserLoginOccupied($login)', '$runtimeType'); + return await _graphQlProvider.checkUserLoginOccupied(login); + } } diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index d5410d9111a..01e9c9241bb 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -99,6 +99,18 @@ class AuthView extends StatelessWidget { // Footer part of the page. List footer = [ const SizedBox(height: 25), + OutlinedRoundedButton( + key: const Key('StartButton'), + maxWidth: 210, + height: 46, + leading: Transform.translate( + offset: const Offset(4, 0), + child: const SvgIcon(SvgIcons.guest), + ), + onPressed: c.register, + child: Text('btn_guest'.l10n), + ), + const SizedBox(height: 15), OutlinedRoundedButton( key: const Key('RegisterButton'), maxWidth: 210, @@ -124,18 +136,6 @@ class AuthView extends StatelessWidget { child: Text('btn_sign_in'.l10n), ), const SizedBox(height: 15), - OutlinedRoundedButton( - key: const Key('StartButton'), - maxWidth: 210, - height: 46, - leading: Transform.translate( - offset: const Offset(4, 0), - child: const SvgIcon(SvgIcons.guest), - ), - onPressed: c.register, - child: Text('btn_guest'.l10n), - ), - const SizedBox(height: 15), ]; final Widget column = Column( diff --git a/lib/ui/page/home/controller.dart b/lib/ui/page/home/controller.dart index 09d719546e7..00950af2e49 100644 --- a/lib/ui/page/home/controller.dart +++ b/lib/ui/page/home/controller.dart @@ -225,12 +225,13 @@ class HomeController extends GetxController { if (link != null) { stage = IntroductionViewStage.link; + } + if (signedUp) { + stage = IntroductionViewStage.signUp; } else if (!myUser.hasPassword && myUser.emails.confirmed.isEmpty && myUser.phones.confirmed.isEmpty) { stage = IntroductionViewStage.oneTime; - } else if (signedUp) { - stage = IntroductionViewStage.signUp; } if (stage != null) { diff --git a/lib/ui/page/login/controller.dart b/lib/ui/page/login/controller.dart index a4238fab5af..778d1e69165 100644 --- a/lib/ui/page/login/controller.dart +++ b/lib/ui/page/login/controller.dart @@ -25,6 +25,7 @@ import '/api/backend/schema.dart' import '/domain/model/my_user.dart'; import '/domain/model/user.dart'; import '/domain/service/auth.dart'; +import '/domain/service/my_user.dart'; import '/l10n/l10n.dart'; import '/provider/gql/exceptions.dart' show @@ -37,6 +38,7 @@ import '/provider/gql/exceptions.dart' ValidateUserPasswordRecoveryCodeException; import '/routes.dart'; import '/ui/widget/text_field.dart'; +import '/util/get.dart'; import '/util/message_popup.dart'; /// Possible [LoginView] flow stage. @@ -47,6 +49,7 @@ enum LoginViewStage { signIn, signInWithPassword, signUp, + signUpWithLogin, signUpWithEmail, signUpWithEmailCode, signUpOrSignIn, @@ -84,6 +87,62 @@ class LoginController extends GetxController { /// [TextFieldState] for [ConfirmationCode] for [UserEmail] input. late final TextFieldState emailCode; + late final TextFieldState inLogin = TextFieldState( + onChanged: (s) async { + s.error.value = null; + inPassword.unsubmit(); + + if (s.text.isNotEmpty) { + try { + UserLogin(s.text); + } on FormatException catch (_) { + s.error.value = 'err_incorrect_login_input'.l10n; + } catch (e) { + s.error.value = 'err_data_transfer'.l10n; + rethrow; + } + } + }, + onSubmitted: (s) { + inPassword.focus.requestFocus(); + s.unsubmit(); + }, + ); + + late final TextFieldState inPassword = TextFieldState( + onChanged: (s) { + s.error.value = null; + inRepeat.error.value = null; + inRepeat.unsubmit(); + + if (s.text != inRepeat.text && inRepeat.isValidated) { + inRepeat.error.value = 'err_passwords_mismatch'.l10n; + } + }, + onSubmitted: (s) async { + inRepeat.focus.requestFocus(); + s.unsubmit(); + await resetUserPassword(); + }, + ); + + late final TextFieldState inRepeat = TextFieldState( + onChanged: (s) { + s.error.value = null; + inRepeat.error.value = null; + inRepeat.unsubmit(); + + if (s.text != inPassword.text && inPassword.isValidated) { + s.error.value = 'err_passwords_mismatch'.l10n; + } + }, + onSubmitted: (s) async { + inRepeat.focus.requestFocus(); + s.unsubmit(); + await signUpWithLogin(); + }, + ); + /// [LoginView] stage to go back to. LoginViewStage? returnTo; @@ -209,7 +268,9 @@ class LoginController extends GetxController { s.error.value = 'err_passwords_mismatch'.l10n; } }, - onSubmitted: (s) => resetUserPassword(), + onSubmitted: (s) async { + await resetUserPassword(); + }, ); email = TextFieldState( @@ -388,6 +449,66 @@ class LoginController extends GetxController { } } + Future signUpWithLogin() async { + if (inLogin.error.value != null || + inPassword.error.value != null || + inRepeat.error.value != null) { + return; + } + + if (UserPassword.tryParse(inPassword.text) == null) { + inPassword.error.value = 'err_incorrect_input'.l10n; + return; + } + + if (UserPassword.tryParse(inRepeat.text) == null) { + inRepeat.error.value = 'err_incorrect_input'.l10n; + return; + } + + if (inPassword.text != inRepeat.text) { + inRepeat.error.value = 'err_passwords_mismatch'.l10n; + return; + } + + inLogin.status.value = RxStatus.loading(); + inPassword.status.value = RxStatus.loading(); + inRepeat.status.value = RxStatus.loading(); + + try { + final UserLogin login = UserLogin(inLogin.text); + final UserPassword password = UserPassword(inPassword.text); + + await _authService.register(); + (onSuccess ?? router.home)(signedUp: true); + + MyUserService? myUserService; + do { + await Future.delayed(const Duration(milliseconds: 50)); + + myUserService = Get.findOrNull(); + + if (myUserService != null) { + try { + await myUserService.updateUserLogin(login); + await myUserService.updateUserPassword(newPassword: password); + } catch (e) { + MessagePopup.error(e); + } + } + } while (myUserService == null); + } on ConnectionException { + MessagePopup.error('err_data_transfer'.l10n); + } catch (e) { + MessagePopup.error(e); + rethrow; + } finally { + inLogin.status.value = RxStatus.empty(); + inPassword.status.value = RxStatus.empty(); + inRepeat.status.value = RxStatus.empty(); + } + } + /// Initiates password recovery for the [MyUser] identified by the provided /// [recovery] input and stores the parsed value. Future recoverAccess() async { diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index ec52ebf9bbd..a9b99f10524 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -291,6 +291,81 @@ class LoginView extends StatelessWidget { ]; break; + case LoginViewStage.signUpWithLogin: + header = ModalPopupHeader( + text: 'label_sign_up'.l10n, + onBack: () { + c.stage.value = LoginViewStage.signUp; + c.inLogin.unsubmit(); + c.inPassword.unsubmit(); + c.inRepeat.unsubmit(); + }, + ); + + children = [ + ReactiveTextField( + state: c.inLogin, + label: 'label_login'.l10n, + style: style.fonts.normal.regular.onBackground, + floatingLabelBehavior: FloatingLabelBehavior.always, + hint: 'alphanumeric-login_123', + treatErrorAsStatus: false, + ), + const SizedBox(height: 16), + ReactiveTextField( + key: const Key('PasswordField'), + state: c.inPassword, + label: 'label_password'.l10n, + obscure: c.obscureNewPassword.value, + onSuffixPressed: c.obscureNewPassword.toggle, + floatingLabelBehavior: FloatingLabelBehavior.always, + hint: '***', + treatErrorAsStatus: false, + trailing: SvgIcon( + c.obscureNewPassword.value + ? SvgIcons.visibleOff + : SvgIcons.visibleOn, + ), + ), + const SizedBox(height: 16), + ReactiveTextField( + key: const Key('RepeatPasswordField'), + state: c.inRepeat, + label: 'label_repeat_password'.l10n, + obscure: c.obscureRepeatPassword.value, + onSuffixPressed: c.obscureRepeatPassword.toggle, + floatingLabelBehavior: FloatingLabelBehavior.always, + hint: '***', + treatErrorAsStatus: false, + trailing: SvgIcon( + c.obscureRepeatPassword.value + ? SvgIcons.visibleOff + : SvgIcons.visibleOn, + ), + ), + const SizedBox(height: 25), + Center( + child: Obx(() { + final bool enabled = !c.inLogin.isEmpty.value && + !c.inPassword.isEmpty.value && + !c.inRepeat.isEmpty.value; + + return OutlinedRoundedButton( + onPressed: enabled ? c.inRepeat.submit : null, + color: style.colors.primary, + maxWidth: double.infinity, + child: Text( + 'btn_proceed'.l10n, + style: enabled + ? style.fonts.medium.regular.onPrimary + : style.fonts.medium.regular.onBackground, + ), + ); + }), + ), + ]; + break; + case LoginViewStage.signUp: header = ModalPopupHeader( text: 'label_sign_up'.l10n, @@ -300,6 +375,13 @@ class LoginView extends StatelessWidget { ); children = [ + SignButton( + title: 'btn_login_and_password'.l10n, + icon: const SvgIcon(SvgIcons.password), + onPressed: () => + c.stage.value = LoginViewStage.signUpWithLogin, + ), + const SizedBox(height: 16), SignButton( title: 'btn_email'.l10n, icon: const SvgIcon(SvgIcons.email), diff --git a/lib/ui/widget/outlined_rounded_button.dart b/lib/ui/widget/outlined_rounded_button.dart index 5efff8eed91..7a9e3f413a4 100644 --- a/lib/ui/widget/outlined_rounded_button.dart +++ b/lib/ui/widget/outlined_rounded_button.dart @@ -154,7 +154,7 @@ class _OutlinedRoundedButtonState extends State { ? widget.disabled ?? style.colors.secondaryHighlight : _hovered ? Color.alphaBlend( - style.colors.onBackgroundOpacity7, + style.colors.onBackgroundOpacity2, widget.color ?? style.colors.onPrimary, ) : widget.color ?? style.colors.onPrimary, From 82f0acf6c67917ee8e02e8d389d2fe94ceef82e5 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 19 Apr 2024 16:22:27 +0300 Subject: [PATCH 28/88] Separate appcast to this branch --- .github/workflows/ci.yml | 64 ++++----- lib/main.dart | 199 ++++++++++++---------------- lib/provider/hive/consent.dart | 47 ------- lib/ui/page/consent/controller.dart | 44 ------ lib/ui/page/consent/view.dart | 114 ---------------- 5 files changed, 117 insertions(+), 351 deletions(-) delete mode 100644 lib/provider/hive/consent.dart delete mode 100644 lib/ui/page/consent/controller.dart delete mode 100644 lib/ui/page/consent/view.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 995ec88059a..c67334c58e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: pr: if: ${{ github.event_name == 'pull_request' }} needs: - # - appcast-edge + - appcast-edge - build - build-linux - copyright @@ -115,30 +115,30 @@ jobs: # Building # ############ - # appcast-edge: - # name: appcast (edge) - # # Despite this CI job is not always needed, we intentionally keep it running - # # always, because we want the `docker` CI job to depend on it, and GitHub - # # Actions doesn't provide any conditional depending at the moment. - # #if: ${{ github.ref == 'refs/heads/main' - # # || github.event_name == 'pull_request' }} - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # with: - # fetch-depth: 0 # for correct versioning via `git describe --tags` + appcast-edge: + name: appcast (edge) + # Despite this CI job is not always needed, we intentionally keep it running + # always, because we want the `docker` CI job to depend on it, and GitHub + # Actions doesn't provide any conditional depending at the moment. + #if: ${{ github.ref == 'refs/heads/main' + # || github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # for correct versioning via `git describe --tags` - # - run: make appcast.xml - # env: - # link: ${{ secrets.ARTIFACTS_MAIN }} - # notes: ${{ secrets.APPCAST_NOTES }} + - run: make appcast.xml + env: + link: ${{ secrets.ARTIFACTS_EDGE }} + notes: ${{ secrets.APPCAST_NOTES }} - # - uses: actions/upload-artifact@v4 - # with: - # name: appcast-edge-${{ github.run_number }} - # path: appcast.xml - # if-no-files-found: error - # retention-days: 1 + - uses: actions/upload-artifact@v4 + with: + name: appcast-edge-${{ github.run_number }} + path: appcast.xml + if-no-files-found: error + retention-days: 1 build: strategy: @@ -206,8 +206,8 @@ jobs: # maps reading. - run: make flutter.build platform=${{ matrix.platform }} profile=yes dart-env='SOCAPP_FCM_VAPID_KEY=${{ secrets.FCM_VAPID_KEY }} - SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_MAIN }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }}' + SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }}' if: ${{ matrix.platform == 'web' }} - name: Prepare Android signing resources @@ -227,9 +227,9 @@ jobs: SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} SOCAPP_WS_PORT=${{ secrets.BACKEND_PORT }} - SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_MAIN }} - SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_MAIN }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }} + SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} + SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' if: ${{ matrix.platform != 'web' }} @@ -370,9 +370,9 @@ jobs: SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} SOCAPP_WS_PORT=${{ secrets.BACKEND_PORT }} - SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_MAIN }} - SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_MAIN }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }} + SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} + SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' - name: Parse application name from Git repository name @@ -425,7 +425,7 @@ jobs: # || startsWith(github.ref, 'refs/tags/v') }} docker: - needs: ["build", "build-linux"] + needs: ["appcast-edge", "build", "build-linux"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/lib/main.dart b/lib/main.dart index d799c3875da..0d5c1352c33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -57,7 +57,6 @@ import 'provider/gql/exceptions.dart'; import 'provider/gql/graphql.dart'; import 'provider/hive/account.dart'; import 'provider/hive/cache.dart'; -import 'provider/hive/consent.dart'; import 'provider/hive/credentials.dart'; import 'provider/hive/download.dart'; import 'provider/hive/my_user.dart'; @@ -68,7 +67,6 @@ import 'routes.dart'; import 'store/auth.dart'; import 'store/model/window_preferences.dart'; import 'themes.dart'; -import 'ui/page/consent/view.dart'; import 'ui/worker/cache.dart'; import 'ui/worker/upgrade.dart'; import 'ui/worker/window.dart'; @@ -149,123 +147,100 @@ Future main() async { ); } - // Initializes and runs [Sentry], if [enabled]. - Future sentryRunner(bool enabled) async { - if (!enabled) { - return appRunner(); - } - - await SentryFlutter.init( - (options) { - options.dsn = Config.sentryDsn; - options.tracesSampleRate = 1.0; - options.sampleRate = 1.0; - options.release = - '${Pubspec.name}@${Pubspec.ref ?? Config.version ?? Pubspec.version}'; - options.debug = true; - options.diagnosticLevel = SentryLevel.info; - options.enablePrintBreadcrumbs = true; - options.maxBreadcrumbs = 512; - options.enableTimeToFullDisplayTracing = true; - options.enableAppHangTracking = true; - options.enableTracing = true; - options.beforeSend = (SentryEvent event, {Hint? hint}) { - final exception = event.exceptions?.firstOrNull?.throwable; - - // Connection related exceptions shouldn't be logged. - if (exception is ConnectionException || - exception is SocketException || - exception is WebSocketException || - exception is WebSocketChannelException || - exception is HttpException || - exception is ClientException || - exception is DioException || - exception is TimeoutException || - exception is ResubscriptionRequiredException) { - return null; - } - - // [Backoff] related exceptions shouldn't be logged. - if (exception is OperationCanceledException || - exception.toString() == 'Data is not loaded') { - return null; - } + // No need to initialize the Sentry if no DSN is provided, otherwise useless + // messages are printed to the console every time the application starts. + if (Config.sentryDsn.isEmpty || kDebugMode) { + return appRunner(); + } - return event; - }; - options.logger = ( - SentryLevel level, - String message, { - String? logger, - Object? exception, - StackTrace? stackTrace, - }) { - if (exception != null) { - if (stackTrace == null) { - stackTrace = StackTrace.current; - } else { - stackTrace = FlutterError.demangleStackTrace(stackTrace); - } + await SentryFlutter.init( + (options) { + options.dsn = Config.sentryDsn; + options.tracesSampleRate = 1.0; + options.sampleRate = 1.0; + options.release = + '${Pubspec.name}@${Pubspec.ref ?? Config.version ?? Pubspec.version}'; + options.debug = true; + options.diagnosticLevel = SentryLevel.info; + options.enablePrintBreadcrumbs = true; + options.maxBreadcrumbs = 512; + options.enableTimeToFullDisplayTracing = true; + options.enableAppHangTracking = true; + options.enableTracing = true; + options.beforeSend = (SentryEvent event, {Hint? hint}) { + final exception = event.exceptions?.firstOrNull?.throwable; + + // Connection related exceptions shouldn't be logged. + if (exception is ConnectionException || + exception is SocketException || + exception is WebSocketException || + exception is WebSocketChannelException || + exception is HttpException || + exception is ClientException || + exception is DioException || + exception is TimeoutException || + exception is ResubscriptionRequiredException) { + return null; + } - final Iterable lines = - stackTrace.toString().trimRight().split('\n').take(100); + // [Backoff] related exceptions shouldn't be logged. + if (exception is OperationCanceledException || + exception.toString() == 'Data is not loaded') { + return null; + } - Log.error( - [ - exception.toString(), - if (lines.where((e) => e.isNotEmpty).isNotEmpty) - FlutterError.defaultStackFilter(lines).join('\n') - ].join('\n'), - ); + return event; + }; + options.logger = ( + SentryLevel level, + String message, { + String? logger, + Object? exception, + StackTrace? stackTrace, + }) { + if (exception != null) { + if (stackTrace == null) { + stackTrace = StackTrace.current; + } else { + stackTrace = FlutterError.demangleStackTrace(stackTrace); } - }; - }, - appRunner: appRunner, - ); - // TODO: Remove, when Sentry supports app start measurement for all platforms. - // ignore: invalid_use_of_internal_member - NativeAppStartIntegration.setAppStartInfo( - AppStartInfo( - AppStartType.cold, - start: DateTime.now().subtract(watch.elapsed), - end: DateTime.now(), - ), - ); + final Iterable lines = + stackTrace.toString().trimRight().split('\n').take(100); - // Transaction indicating Flutter engine has rasterized the first frame. - final ISentrySpan ready = Sentry.startTransaction( - 'ui.app.ready', - 'ui', - autoFinishAfter: const Duration(minutes: 2), - startTimestamp: DateTime.now().subtract(watch.elapsed), - )..startChild('ready'); + Log.error( + [ + exception.toString(), + if (lines.where((e) => e.isNotEmpty).isNotEmpty) + FlutterError.defaultStackFilter(lines).join('\n') + ].join('\n'), + ); + } + }; + }, + appRunner: appRunner, + ); - WidgetsBinding.instance.waitUntilFirstFrameRasterized - .then((_) => ready.finish()); - } + // TODO: Remove, when Sentry supports app start measurement for all platforms. + // ignore: invalid_use_of_internal_member + NativeAppStartIntegration.setAppStartInfo( + AppStartInfo( + AppStartType.cold, + start: DateTime.now().subtract(watch.elapsed), + end: DateTime.now(), + ), + ); - // No need to initialize the Sentry if no DSN is provided, otherwise useless - // messages are printed to the console every time the application starts. - if (Config.sentryDsn.isEmpty || kDebugMode) { - return appRunner(); - } else { - final consentProvider = Get.findOrNull(); - final consent = consentProvider == null ? true : consentProvider.get(); + // Transaction indicating Flutter engine has rasterized the first frame. + final ISentrySpan ready = Sentry.startTransaction( + 'ui.app.ready', + 'ui', + autoFinishAfter: const Duration(minutes: 2), + startTimestamp: DateTime.now().subtract(watch.elapsed), + )..startChild('ready'); - if (consent != null) { - // If user has already provided the consent, then try running [Sentry]. - await sentryRunner(consent); - } else { - // Or otherwise ask the user for the consent of [Sentry] usage. - runApp( - MaterialApp( - theme: Themes.light(), - home: ConsentView(sentryRunner), - ), - ); - } - } + WidgetsBinding.instance.waitUntilFirstFrameRasterized + .then((_) => ready.finish()); } /// Initializes the [FlutterCallkitIncoming] and displays an incoming call @@ -492,10 +467,6 @@ Future _initHive() async { await Get.put(CacheInfoHiveProvider()).init(); await Get.put(DownloadHiveProvider()).init(); } - - if (PlatformUtils.isIOS) { - await Get.put(ConsentHiveProvider()).init(); - } } /// Extension adding an ability to clean [Hive]. diff --git a/lib/provider/hive/consent.dart b/lib/provider/hive/consent.dart deleted file mode 100644 index 59a3582c3ee..00000000000 --- a/lib/provider/hive/consent.dart +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:hive_flutter/adapters.dart'; - -import '/util/log.dart'; -import 'base.dart'; - -/// [Hive] storage for a consent boolean storage. -class ConsentHiveProvider extends HiveBaseProvider { - @override - Stream get boxEvents => box.watch(key: 0); - - @override - String get boxName => 'consent'; - - @override - void registerAdapters() { - Log.debug('registerAdapters()', '$runtimeType'); - } - - /// Returns the stored consent value from [Hive]. - bool? get() { - Log.trace('get()', '$runtimeType'); - return getSafe(0); - } - - /// Stores new consent [value] to [Hive]. - Future set(bool value) async { - Log.trace('set($value)', '$runtimeType'); - await putSafe(0, value); - } -} diff --git a/lib/ui/page/consent/controller.dart b/lib/ui/page/consent/controller.dart deleted file mode 100644 index 25e1e06db28..00000000000 --- a/lib/ui/page/consent/controller.dart +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:get/get.dart'; - -import '/provider/hive/consent.dart'; - -/// Controller of a [ConsentView]. -class ConsentController extends GetxController { - ConsentController(this._consentProvider, this.callback); - - /// Status of the [proceed] completing the [callback]. - final Rx status = Rx(RxStatus.empty()); - - /// Function to call after acquiring the user's consent. - final Future Function(bool) callback; - - /// [ConsentHiveProvider] providing and storing the consent itself. - final ConsentHiveProvider _consentProvider; - - /// Stores the [consent] and invokes the [callback]. - Future proceed(bool consent) async { - status.value = RxStatus.loading(); - - await _consentProvider.set(consent); - await callback(consent); - - status.value = RxStatus.success(); - } -} diff --git a/lib/ui/page/consent/view.dart b/lib/ui/page/consent/view.dart deleted file mode 100644 index 81c0616f9e0..00000000000 --- a/lib/ui/page/consent/view.dart +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - -import '/l10n/l10n.dart'; -import '/themes.dart'; -import '/ui/page/auth/widget/cupertino_button.dart'; -import '/ui/page/login/widget/primary_button.dart'; -import '/ui/page/work/widget/project_block.dart'; -import '/ui/widget/svg/svg.dart'; -import 'controller.dart'; - -/// Page for acquiring user's consent about [Sentry] data collection. -class ConsentView extends StatelessWidget { - const ConsentView(this.callback, {super.key}); - - /// Callback, called when consent is acquired. - final Future Function(bool) callback; - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).style; - - return GetBuilder( - init: ConsentController(Get.find(), callback), - builder: (ConsentController c) { - return Stack( - fit: StackFit.expand, - children: [ - IgnorePointer( - child: ColoredBox(color: style.colors.background), - ), - const IgnorePointer( - child: SvgImage.asset( - 'assets/images/background_light.svg', - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ), - ), - Obx(() { - if (c.status.value.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - - return Scaffold( - appBar: AppBar(title: Text('label_anonymous_reports'.l10n)), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: Center( - child: ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - shrinkWrap: true, - children: [ - ProjectBlock( - children: [ - const SizedBox(height: 16), - Text( - 'label_anonymous_reports_description'.l10n, - style: - style.fonts.medium.regular.onBackground, - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: PrimaryButton( - title: 'btn_allow'.l10n, - onPressed: () => c.proceed(true), - ), - ), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: StyledCupertinoButton( - onPressed: () => c.proceed(false), - label: 'btn_do_not_allow'.l10n, - ), - ), - const SizedBox(height: 32), - ], - ), - ), - ); - }), - ], - ); - }, - ); - } -} From c887a51f829cb343e959c044732ae0bbd58418bb Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 19 Apr 2024 17:57:14 +0300 Subject: [PATCH 29/88] Improve sign in UI/UX --- assets/l10n/en-US.ftl | 11 ++-- assets/l10n/ru-RU.ftl | 11 ++-- lib/ui/page/login/controller.dart | 6 +- lib/ui/page/login/view.dart | 69 ++++++++++++++++++++- lib/ui/page/login/widget/prefix_button.dart | 23 +++++-- lib/ui/page/login/widget/sign_button.dart | 16 ++++- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index c6a37053fde..29a3a71f273 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -321,8 +321,8 @@ err_email_occupied = Specified E-mail is linked to another account. Please, annu err_incorrect_chat_name = Incorrect name err_incorrect_email = Incorrect E-mail. err_incorrect_input = Incorrect input. -err_incorrect_login_input = Unique login should contain only letters of the latin alphabet, numbers and symbols "-" and "_". It must start with a letter or number and be at least 3 and max 20 characters long. -err_incorrect_login_or_password = Invalid login or password +err_incorrect_login_input = Login should contain only letters of the latin alphabet, numbers and symbols "-" and "_". It must start with a letter or number and be at least 3 and max 20 characters long. +err_incorrect_login_or_password = Incorrect account identifier or password. err_incorrect_phone = Incorrect phone number. err_input_empty = Must not be empty. err_invalid_crop_coordinates = Invalid crop coordinates @@ -786,7 +786,7 @@ label_introduction_description1 = • you click the button "Sign out". - To save access to your account, please set a password, e-mail or phone number in the{" "} + To save access to your account, please set a password or e-mail number in the{" "} label_introduction_description2 = settings label_introduction_description3 = . label_kb = {$amount} KB @@ -865,6 +865,7 @@ label_notifications = Notifications label_num = Gapopa ID label_off = Off label_offline = offline +label_one_time_password = One time password label_online = online label_open_calls_in_app = In the application label_open_calls_in_window = In a separate window @@ -921,7 +922,7 @@ label_reason = Reason label_recent = Recent label_reconnecting_ellipsis = Reconnecting... label_recover_account = Access recovery -label_recover_account_description = Specify your Gapopa ID, login, E-mail or phone number. +label_recover_account_description = Specify your Gapopa ID, login or E-mail. label_recovery_code = Recovery code label_recovery_code_sent = The verification code has been sent to the verified E-mail/phone linked to this account. Please, enter the code below. label_recovery_enter_new_password = Please enter the new password below. @@ -991,7 +992,7 @@ label_set_password = Set password label_settings = Settings label_show_sections = Show sections label_sign_in = Sign in -label_sign_in_input = Gapopa ID, login, E-mail or phone number +label_sign_in_input = Gapopa ID, login, E-mail label_sign_in_with_password = Sign in with password label_sign_up = Sign up label_sign_up_code_email_sent = Verification code has been sent to the e-mail {$text} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index d6687b5f8da..c1a19492fa1 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -322,8 +322,8 @@ err_email_occupied = Указанный E-mail привязан к другом err_incorrect_chat_name = Некорректное имя err_incorrect_email = Некорректный E-mail. err_incorrect_input = Некорректный формат. -err_incorrect_login_input = Уникальный логин должен содержать только буквы латинского алфавита, цифры и символы "-" и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. -err_incorrect_login_or_password = Неверный логин или пароль +err_incorrect_login_input = Логин должен содержать только буквы латинского алфавита, цифры и символы "-" и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. +err_incorrect_login_or_password = Некорректный идентификатор аккаунта или пароль. err_incorrect_phone = Некорректный номер телефона. err_input_empty = Поле не должно быть пустым. err_invalid_crop_coordinates = Неверные координаты обрезки @@ -813,7 +813,7 @@ label_introduction_description1 = Чтобы сохранить доступ к аккаунту, пожалуйста, в{" "} label_introduction_description2 = настройках -label_introduction_description3 = {" "}задайте пароль, e-mail или номер телефона. +label_introduction_description3 = {" "}задайте пароль или e-mail. label_kb = {$amount} КБ label_language = Язык label_language_entry = {$code}, {$name} @@ -892,6 +892,7 @@ label_notifications = Уведомления label_num = Gapopa ID label_off = Выкл label_offline = офлайн +label_one_time_password = Одноразовый пароль label_online = онлайн label_open_calls_in_app = В окне приложения label_open_calls_in_window = В отдельном окне @@ -948,7 +949,7 @@ label_reason = Причина label_recent = Недавние label_reconnecting_ellipsis = Переподключение... label_recover_account = Восстановление доступа -label_recover_account_description = Укажите Ваш Gapopa ID, логин, E-mail или номер телефона. +label_recover_account_description = Укажите Ваш Gapopa ID, логин или E-mail. label_recovery_code = Код восстановления label_recovery_code_sent = Проверочный код отправлен на верифицрованный E-mail/телефон, указанный для данного аккаунта. Пожалуйста, введите код ниже. label_recovery_enter_new_password = Пожалуйста, введите новый пароль ниже. @@ -1019,7 +1020,7 @@ label_set_password = Задать пароль label_settings = Настройки label_show_sections = Показывать разделы label_sign_in = Войти -label_sign_in_input = Gapopa ID, логин, E-mail или номер телефона +label_sign_in_input = Gapopa ID, логин или E-mail label_sign_in_with_password = Войти с паролем label_sign_up = Регистрация label_sign_up_code_email_sent = diff --git a/lib/ui/page/login/controller.dart b/lib/ui/page/login/controller.dart index 778d1e69165..9467f2999e1 100644 --- a/lib/ui/page/login/controller.dart +++ b/lib/ui/page/login/controller.dart @@ -48,6 +48,7 @@ enum LoginViewStage { recoveryPassword, signIn, signInWithPassword, + signInWithEmail, signUp, signUpWithLogin, signUpWithEmail, @@ -94,12 +95,9 @@ class LoginController extends GetxController { if (s.text.isNotEmpty) { try { - UserLogin(s.text); + UserLogin(s.text.toLowerCase()); } on FormatException catch (_) { s.error.value = 'err_incorrect_login_input'.l10n; - } catch (e) { - s.error.value = 'err_data_transfer'.l10n; - rethrow; } } }, diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index a9b99f10524..c3b5f80c96d 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -20,6 +20,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import '/domain/model/user.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; import '/ui/page/home/page/chat/widget/chat_item.dart'; @@ -266,7 +267,8 @@ class LoginView extends StatelessWidget { ReactiveTextField( state: c.email, label: 'label_email'.l10n, - hint: 'example@domain.com', + hint: 'example@gapopa.com', + floatingLabelBehavior: FloatingLabelBehavior.always, style: style.fonts.normal.regular.onBackground, treatErrorAsStatus: false, ), @@ -310,6 +312,16 @@ class LoginView extends StatelessWidget { floatingLabelBehavior: FloatingLabelBehavior.always, hint: 'alphanumeric-login_123', treatErrorAsStatus: false, + onChanged: () { + if (c.inLogin.text.length > 2) { + try { + UserLogin(c.inLogin.text.toLowerCase()); + } on FormatException catch (_) { + c.inLogin.error.value = + 'err_incorrect_login_input'.l10n; + } + } + }, ), const SizedBox(height: 16), ReactiveTextField( @@ -393,6 +405,45 @@ class LoginView extends StatelessWidget { ]; break; + case LoginViewStage.signInWithEmail: + header = ModalPopupHeader( + text: 'label_sign_in'.l10n, + onBack: () { + c.stage.value = LoginViewStage.signIn; + c.email.unsubmit(); + }, + ); + + children = [ + ReactiveTextField( + state: c.email, + label: 'label_email'.l10n, + hint: 'example@gapopa.com', + floatingLabelBehavior: FloatingLabelBehavior.always, + style: style.fonts.normal.regular.onBackground, + treatErrorAsStatus: false, + ), + const SizedBox(height: 25), + Center( + child: Obx(() { + final bool enabled = !c.email.isEmpty.value; + + return OutlinedRoundedButton( + onPressed: enabled ? () {} : null, + color: style.colors.primary, + maxWidth: double.infinity, + child: Text( + 'btn_proceed'.l10n, + style: enabled + ? style.fonts.medium.regular.onPrimary + : style.fonts.medium.regular.onBackground, + ), + ); + }), + ), + ]; + break; + case LoginViewStage.signInWithPassword: header = ModalPopupHeader( text: 'label_sign_in_with_password'.l10n, @@ -419,7 +470,10 @@ class LoginView extends StatelessWidget { ? SvgIcons.visibleOff : SvgIcons.visibleOn, ), - subtitle: WidgetButton( + ), + Padding( + padding: const EdgeInsets.fromLTRB(21, 8, 8, 8), + child: WidgetButton( onPressed: () { c.recovery.clear(); c.recoveryCode.clear(); @@ -471,6 +525,17 @@ class LoginView extends StatelessWidget { padding: const EdgeInsets.only(left: 1), ), const SizedBox(height: 16), + SignButton( + key: const Key('OtpEmailButton'), + title: 'label_email'.l10n, + subtitle: 'label_one_time_password'.l10n, + dense: true, + onPressed: () => + c.stage.value = LoginViewStage.signInWithEmail, + icon: const SvgIcon(SvgIcons.email), + padding: const EdgeInsets.only(left: 1), + ), + const SizedBox(height: 16), _terms(context), ]; break; diff --git a/lib/ui/page/login/widget/prefix_button.dart b/lib/ui/page/login/widget/prefix_button.dart index 17a4c31f461..ee570bc0356 100644 --- a/lib/ui/page/login/widget/prefix_button.dart +++ b/lib/ui/page/login/widget/prefix_button.dart @@ -24,6 +24,7 @@ class PrefixButton extends StatelessWidget { const PrefixButton({ super.key, this.title = '', + this.subtitle, this.onPressed, this.style, this.prefix, @@ -31,6 +32,7 @@ class PrefixButton extends StatelessWidget { /// Title of this [PrefixButton]. final String title; + final String? subtitle; /// [TextStyle] of the [title]. final TextStyle? style; @@ -76,11 +78,22 @@ class PrefixButton extends StatelessWidget { children: [ const SizedBox(width: 8), Expanded( - child: DefaultTextStyle.merge( - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: style, - child: Center(child: Text(title)), + child: Column( + children: [ + DefaultTextStyle.merge( + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: style, + child: Center(child: Text(title)), + ), + if (subtitle != null) + DefaultTextStyle.merge( + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: styles.fonts.small.regular.secondary, + child: Center(child: Text(subtitle!)), + ), + ], ), ), const SizedBox(width: 8), diff --git a/lib/ui/page/login/widget/sign_button.dart b/lib/ui/page/login/widget/sign_button.dart index c21b1f07e7d..d24744f24da 100644 --- a/lib/ui/page/login/widget/sign_button.dart +++ b/lib/ui/page/login/widget/sign_button.dart @@ -25,13 +25,16 @@ class SignButton extends StatelessWidget { const SignButton({ super.key, required this.title, + this.subtitle, this.icon, this.padding = EdgeInsets.zero, this.onPressed, + this.dense = false, }); /// Title of this [SignButton]. final String title; + final String? subtitle; /// Widget to display as a [PrefixButton.prefix]. final Widget? icon; @@ -42,6 +45,8 @@ class SignButton extends StatelessWidget { /// Callback, called when this button is pressed. final void Function()? onPressed; + final bool dense; + @override Widget build(BuildContext context) { final style = Theme.of(context).style; @@ -49,9 +54,14 @@ class SignButton extends StatelessWidget { return Center( child: PrefixButton( title: title, - style: onPressed == null - ? style.fonts.medium.regular.secondary - : style.fonts.medium.regular.onBackground, + subtitle: subtitle, + style: dense + ? onPressed == null + ? style.fonts.small.regular.secondary + : style.fonts.small.regular.onBackground + : onPressed == null + ? style.fonts.medium.regular.secondary + : style.fonts.medium.regular.onBackground, onPressed: onPressed, prefix: icon == null ? null From 4015309df923bff574a2f92ee423174a9c2255a2 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 11:00:36 +0300 Subject: [PATCH 30/88] Boot --- CHANGELOG.md | 6 ++ assets/conf.toml | 10 +++ assets/l10n/en-US.ftl | 8 +++ assets/l10n/ru-RU.ftl | 8 +++ ios/Runner/Info.plist | 111 ++++++++++++++++---------------- lib/config.dart | 17 +++++ lib/routes.dart | 17 ++++- lib/ui/page/home/router.dart | 7 ++ lib/ui/page/support/view.dart | 118 ++++++++++++++++++++++++++++++++++ 9 files changed, 244 insertions(+), 58 deletions(-) create mode 100644 lib/ui/page/support/view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 30489c2b7f3..84dbae5dfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ All user visible changes to this project will be documented in this file. This p [Diff](/../../compare/v0.1.0-alpha.13.3...v0.1.0-alpha.14) | [Milestone](/../../milestone/22) +### Added + +- UI: + - Support page. ([#971]) + ### Changed - UI: @@ -18,6 +23,7 @@ All user visible changes to this project will be documented in this file. This p - Contacts button moved to chats tab app bar. ([#970]) [#970]: /../../pull/970 +[#971]: /../../pull/971 diff --git a/assets/conf.toml b/assets/conf.toml index 26f7b59faf1..edbb9b93aa2 100644 --- a/assets/conf.toml +++ b/assets/conf.toml @@ -123,3 +123,13 @@ # # Default: # copyright = "" + +# Email address of the support service users can reach on the support page. +# +# Default: +# support = "admin@gapopa.com" + +# URL of the repository (or anything else) for users to report bugs to. +# +# Default: +# repository = "https://github.com/team113/messenger/issues" diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index fda2f182358..50be1b8285d 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -199,6 +199,7 @@ btn_download_as_all = Download all as btn_download_application = Download application btn_edit = Edit btn_email = E-mail +btn_feedback = Feedback btn_file = File btn_forgot_password = Forgot password? btn_forward = Forward @@ -239,6 +240,8 @@ btn_rename = Rename btn_reply = Reply btn_reply_message = Reply to message btn_report = Report +btn_report_a_concern = Report a concern +btn_report_a_bug = Report a bug btn_resend = Resend btn_resend_code = Resend confirmation code btn_resend_message = Resend message @@ -662,6 +665,7 @@ label_connection_lost = Connection lost label_connection_restored = Connection restored label_contact = Contact label_contact_information = Contact information +label_contact_us_via_provided_email = Please, contact us by email {$email}. label_contacts = Contacts label_copied = Copied label_copy = Copy @@ -933,6 +937,8 @@ label_regulations_freelance = 6. The frontend team has the right to refuse to continue collaboration if the code you offered for review is of obviously low quality. label_remove_member = Remove member label_repeat_password = Repeat password +label_replace_this_text_with_concern = Please, replace this text with the concern you want to share. +label_replace_this_text_with_feedback = Please, replace this text with the feedback you want to share. label_replies = [{$count} {$count -> [1] reply *[other] replies @@ -1000,6 +1006,7 @@ label_subtitle_participants = {$count} {$count -> [1] participant *[other] participants } +label_support_service = Support service label_synchronization = Synchronization... label_tab_chats = Chats label_tab_contacts = Contacts @@ -1095,6 +1102,7 @@ label_welcome_message_vacancy = label_welcome_message_vacancy_24_hours = Good afternoon. Please upload your resume in PDF format. Within 24 hours you will be sent the date and time of the interview. +label_what_we_can_help_you_with = What can we help you with? label_work_with_us = Work with us label_work_with_us_desc = Work diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index f199e2ffbc0..a601ad0c0cf 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -199,6 +199,7 @@ btn_download_as = Скачать как btn_download_application = Скачать приложение btn_edit = Редактировать btn_email = E-mail +btn_feedback = Пожелания и предложения btn_file = Файл btn_forgot_password = Забыли пароль? btn_forward = Переслать @@ -239,6 +240,8 @@ btn_rename = Переименовать btn_reply = Ответить btn_reply_message = Ответить на сообщение btn_report = Пожаловаться +btn_report_a_concern = Сообщить о нарушении +btn_report_a_bug = Сообщить о технической проблеме btn_resend = Повторить btn_resend_code = Отправить код ещё раз btn_resend_message = Повторить отправку @@ -686,6 +689,7 @@ label_connection_lost = Связь с сервером потеряна label_connection_restored = Связь восстановлена label_contact = Контакт label_contact_information = Контактная информация +label_contact_us_via_provided_email = Пожалуйста, свяжитесь с нами по эмейлу {$email}. label_contacts = Контакты label_copied = Скопировано label_copy = Копировать @@ -960,6 +964,8 @@ label_regulations_freelance = 6. Команда фронтэнда оставляет за собой право отказаться от сотрудничества, если предложенный на ревью код заведомо низкого качества. label_remove_member = Удалить участника label_repeat_password = Повторите пароль +label_replace_this_text_with_concern = Пожалуйста, поделитесь здесь проблемой, которую Вы заметили. +label_replace_this_text_with_feedback = Пожалуйста, поделитесь своими наблюдениями или предложениями. label_replies = [{$count} {$count -> [1] ответ [few] ответа @@ -1030,6 +1036,7 @@ label_subtitle_participants = {$count} {$count -> [few] участника *[other] участников } +label_support_service = Служба поддержки label_synchronization = Синхронизация... label_tab_chats = Чаты label_tab_contacts = Контакты @@ -1125,6 +1132,7 @@ label_welcome_message_vacancy = label_welcome_message_vacancy_24_hours = Добрый день. Пожалуйста, отправьте Ваше резюме в формате PDF. В течение 24 часов Вам будет отправлена дата и время интервью. +label_what_we_can_help_you_with = Чем мы можем Вам помочь? label_work_with_us = Работайте с нами label_work_with_us_desc = Работайте diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4e0f1ae15a6..90584c3608b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,65 +2,64 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Gapopa - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - messenger - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIBackgroundModes - - fetch - processing - remote-notification - voip - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSPhotoLibraryUsageDescription - We need access to your photos to save and upload them - NSCameraUsageDescription - We need access to your camera to take photos/videos - NSMicrophoneUsageDescription - We need access to your microphone to record it - LSSupportsOpeningDocumentsInPlace - CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gapopa + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + messenger + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSCameraUsageDescription + We need access to your camera to take photos/videos + NSMicrophoneUsageDescription + We need access to your microphone to record it + NSPhotoLibraryUsageDescription + We need access to your photos to save and upload them UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + fetch + processing + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + diff --git a/lib/config.dart b/lib/config.dart index 3eac0406239..ae4c3744603 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -103,6 +103,13 @@ class Config { /// Optional copyright to display at the bottom of [Routes.auth] page. static String copyright = ''; + /// Email address of the support service displayed on the [Routes.support] + /// page. + static String support = 'admin@gapopa.com'; + + /// URL of the repository (or anything else) for users to report bugs to. + static String repository = 'https://github.com/team113/messenger/issues'; + /// Initializes this [Config] by applying values from the following sources /// (in the following order): /// - compile-time environment variables; @@ -190,6 +197,14 @@ class Config { ? const String.fromEnvironment('SOCAPP_LEGAL_COPYRIGHT') : (document['legal']?['copyright'] ?? copyright); + support = const bool.hasEnvironment('SOCAPP_LEGAL_SUPPORT') + ? const String.fromEnvironment('SOCAPP_LEGAL_SUPPORT') + : (document['legal']?['support'] ?? support); + + repository = const bool.hasEnvironment('SOCAPP_LEGAL_REPOSITORY') + ? const String.fromEnvironment('SOCAPP_LEGAL_REPOSITORY') + : (document['legal']?['repository'] ?? repository); + // Change default values to browser's location on web platform. if (PlatformUtils.isWeb) { if (document['server']?['http']?['url'] == null && @@ -253,6 +268,8 @@ class Config { copyright = remote['legal']?[Uri.base.host]['copyright'] ?? remote['legal']?['copyright'] ?? copyright; + support = remote['legal']?['support'] ?? support; + repository = remote['legal']?['repository'] ?? repository; if (remote['log']?['level'] != null) { logLevel = me.LogLevel.values.firstWhere( (e) => e.name == remote['log']?['level'], diff --git a/lib/routes.dart b/lib/routes.dart index be54aabc75d..3e3fc8fcc61 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -74,6 +74,7 @@ import 'ui/page/erase/view.dart'; import 'ui/page/home/view.dart'; import 'ui/page/popup_call/view.dart'; import 'ui/page/style/view.dart'; +import 'ui/page/support/view.dart'; import 'ui/page/work/view.dart'; import 'ui/widget/lifecycle_observer.dart'; import 'ui/worker/call.dart'; @@ -95,12 +96,13 @@ class Routes { static const chatInfo = '/info'; static const chats = '/chats'; static const contacts = '/contacts'; + static const erase = '/erase'; static const home = '/'; static const me = '/me'; static const menu = '/menu'; + static const support = '/support'; static const user = '/user'; static const work = '/work'; - static const erase = '/erase'; // E2E tests related page, should not be used in non-test environment. static const restart = '/restart'; @@ -280,7 +282,9 @@ class RouterState extends ChangeNotifier { /// - [Routes.home] is allowed always. /// - Any other page is allowed to visit only on success auth status. String _guarded(String to) { - if (to.startsWith(Routes.work) || to.startsWith(Routes.erase)) { + if (to.startsWith(Routes.work) || + to.startsWith(Routes.erase) || + to.startsWith(Routes.support)) { return to; } @@ -792,6 +796,14 @@ class AppRouterDelegate extends RouterDelegate child: EraseView(), ) ]; + } else if (_state.route.startsWith(Routes.support)) { + return const [ + MaterialPage( + key: ValueKey('SupportPage'), + name: Routes.support, + child: SupportView(), + ) + ]; } else { pages.add(const MaterialPage( key: ValueKey('AuthPage'), @@ -805,6 +817,7 @@ class AppRouterDelegate extends RouterDelegate _state.route.startsWith(Routes.user) || _state.route.startsWith(Routes.work) || _state.route.startsWith(Routes.erase) || + _state.route.startsWith(Routes.support) || _state.route == Routes.me || _state.route == Routes.home) { _updateTabTitle(); diff --git a/lib/ui/page/home/router.dart b/lib/ui/page/home/router.dart index 680a0439905..d826973d925 100644 --- a/lib/ui/page/home/router.dart +++ b/lib/ui/page/home/router.dart @@ -25,6 +25,7 @@ import '/domain/model/contact.dart'; import '/domain/model/user.dart'; import '/routes.dart'; import '/ui/page/erase/view.dart'; +import '/ui/page/support/view.dart'; import '/ui/page/work/page/vacancy/view.dart'; import '/ui/widget/custom_page.dart'; import 'page/chat/info/view.dart'; @@ -120,6 +121,12 @@ class HomeRouterDelegate extends RouterDelegate name: Routes.erase, child: EraseView(), )); + } else if (route.startsWith(Routes.support)) { + pages.add(const CustomPage( + key: ValueKey('SupportPage'), + name: Routes.support, + child: SupportView(), + )); } } diff --git a/lib/ui/page/support/view.dart b/lib/ui/page/support/view.dart new file mode 100644 index 00000000000..5f0b838c147 --- /dev/null +++ b/lib/ui/page/support/view.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '/config.dart'; +import '/l10n/l10n.dart'; +import '/themes.dart'; +import '/ui/page/home/widget/app_bar.dart'; +import '/ui/page/home/widget/block.dart'; +import '/ui/page/work/widget/project_block.dart'; +import '/ui/widget/outlined_rounded_button.dart'; +import '/util/message_popup.dart'; + +/// [Routes.support] page. +class SupportView extends StatelessWidget { + const SupportView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: Text('label_support_service'.l10n)), + body: ListView( + children: [ + const ProjectBlock(), + Block( + title: 'label_what_we_can_help_you_with'.l10n, + children: [ + _button( + context, + title: 'btn_report_a_concern'.l10n, + onPressed: () async { + await _mail( + context, + subject: '[App] Report a concern', + body: 'label_replace_this_text_with_concern'.l10n, + ); + }, + ), + const SizedBox(height: 8), + _button( + context, + title: 'btn_report_a_bug'.l10n, + onPressed: () async { + await launchUrlString(Config.repository); + }, + ), + const SizedBox(height: 8), + _button( + context, + title: 'btn_feedback'.l10n, + onPressed: () async { + await _mail( + context, + subject: '[App] Feedback', + body: 'label_replace_this_text_with_feedback'.l10n, + ); + }, + ), + ], + ), + ], + ), + ); + } + + /// Returns an [OutlinedRoundedButton] with the provided [title]. + Widget _button( + BuildContext context, { + required String title, + void Function()? onPressed, + }) { + final style = Theme.of(context).style; + + return OutlinedRoundedButton( + maxWidth: double.infinity, + height: null, + onPressed: onPressed, + color: style.colors.primary, + child: Text( + title, + style: style.fonts.medium.regular.onPrimary, + maxLines: 10, + ), + ); + } + + /// Launches the email scheme to the [Config.support] with provided [subject] + /// and [body]. + Future _mail( + BuildContext context, { + required String subject, + required String body, + }) async { + String? encodeQueryParameters(Map params) { + return params.entries + .map((e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + } + + try { + await launchUrl( + Uri( + scheme: 'mailto', + path: Config.support, + query: encodeQueryParameters({ + 'subject': subject, + 'body': '$body\n\n', + }), + ), + ); + } catch (e) { + await MessagePopup.error('label_contact_us_via_provided_email'.l10nfmt({ + 'email': Config.support, + })); + } + } +} From 971d966b6cbe52fd56dfe55e1ca307b868ab56e3 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 11:06:18 +0300 Subject: [PATCH 31/88] Fix pubspec --- pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 031bacdf62e..34b98ea25ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,6 @@ dependencies: flutter_background_service_ios: ^2.4.0 flutter_callkit_incoming: ^2.0.3 flutter_custom_cursor: ^0.0.4 - flutter_callkit_incoming: ^2.0.3 flutter_list_view: git: https://github.com/krida2000/flutter_list_view.git flutter_local_notifications: ^16.1.0 From 01143c0ae1647ab258824c277d7380b46206e9cc Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 12:08:11 +0300 Subject: [PATCH 32/88] Update iOS plist file [skip ci] --- ios/Podfile.lock | 26 +++++++++++++------------- ios/Runner.xcodeproj/project.pbxproj | 15 +++++++++------ ios/Runner/Info.plist | 2 -- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b793774b5e9..62a87d85e7b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,14 +9,14 @@ PODS: - CryptoSwift (1.8.2) - device_info_plus (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/Core (4.3.8): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.4) - - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/ImageDataManager (4.3.8) + - DKImagePickerController/PhotoGallery (4.3.8): - DKImagePickerController/Core - DKPhotoGallery - - DKImagePickerController/Resource (4.3.4) + - DKImagePickerController/Resource (4.3.8) - DKPhotoGallery (0.0.17): - DKPhotoGallery/Core (= 0.0.17) - DKPhotoGallery/Model (= 0.0.17) @@ -59,9 +59,9 @@ PODS: - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreInternal (10.23.0): + - FirebaseCoreInternal (10.24.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseInstallations (10.23.0): + - FirebaseInstallations (10.24.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -151,7 +151,7 @@ PODS: - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) - - ReachabilitySwift (5.2.1) + - ReachabilitySwift (5.2.2) - screen_brightness_ios (0.1.0): - Flutter - SDWebImage (5.19.1): @@ -171,7 +171,7 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - SwiftyGif (5.4.4) + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - vibration (1.7.5): @@ -316,15 +316,15 @@ SPEC CHECKSUMS: connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d CryptoSwift: c63a805d8bb5e5538e88af4e44bb537776af11ea device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 - DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de Firebase: 10c8cb12fb7ad2ae0c09ffc86cd9c1ab392a0031 firebase_core: a46c312d8bae4defa3d009b2aa7b5b413aeb394e firebase_messaging: e7062cef946e12f93b42abea96937004f8d914d6 FirebaseCore: 28045c1560a2600d284b9c45a904fe322dc890b6 - FirebaseCoreInternal: 6a292e6f0bece1243a737e81556e56e5e19282e3 - FirebaseInstallations: 42d6ead4605d6eafb3b6683674e80e18eb6f2c35 + FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af + FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e FirebaseMessaging: 06c414a21b122396a26847c523d5c370f8325df5 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_app_badger: b87fc231847b03b92ce1412aa351842e7e97932f @@ -350,7 +350,7 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66 + ReachabilitySwift: 2128f3a8c9107e1ad33574c6e58e8285d460b149 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb sensors: 84eb7a30e47a649e4172b71d6e81be614c280336 @@ -359,7 +359,7 @@ SPEC CHECKSUMS: SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index eb9213cbf8a..8501191ffe9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -494,11 +494,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -627,11 +628,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -654,11 +656,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -691,7 +694,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -726,7 +729,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -760,7 +763,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 90584c3608b..c2e829acd09 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -38,8 +38,6 @@ UIBackgroundModes - fetch - processing remote-notification UILaunchStoryboardName From caefbc790429548f78fc09223bacc24351a3458f Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 12:10:17 +0300 Subject: [PATCH 33/88] Update iOS meta files --- ios/Podfile.lock | 26 +++++++++++++------------- ios/Runner.xcodeproj/project.pbxproj | 15 +++++++++------ ios/Runner/Info.plist | 2 -- lib/ui/page/support/view.dart | 17 +++++++++++++++++ 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b793774b5e9..62a87d85e7b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,14 +9,14 @@ PODS: - CryptoSwift (1.8.2) - device_info_plus (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/Core (4.3.8): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.4) - - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/ImageDataManager (4.3.8) + - DKImagePickerController/PhotoGallery (4.3.8): - DKImagePickerController/Core - DKPhotoGallery - - DKImagePickerController/Resource (4.3.4) + - DKImagePickerController/Resource (4.3.8) - DKPhotoGallery (0.0.17): - DKPhotoGallery/Core (= 0.0.17) - DKPhotoGallery/Model (= 0.0.17) @@ -59,9 +59,9 @@ PODS: - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreInternal (10.23.0): + - FirebaseCoreInternal (10.24.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseInstallations (10.23.0): + - FirebaseInstallations (10.24.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -151,7 +151,7 @@ PODS: - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) - - ReachabilitySwift (5.2.1) + - ReachabilitySwift (5.2.2) - screen_brightness_ios (0.1.0): - Flutter - SDWebImage (5.19.1): @@ -171,7 +171,7 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - SwiftyGif (5.4.4) + - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - vibration (1.7.5): @@ -316,15 +316,15 @@ SPEC CHECKSUMS: connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d CryptoSwift: c63a805d8bb5e5538e88af4e44bb537776af11ea device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 - DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de Firebase: 10c8cb12fb7ad2ae0c09ffc86cd9c1ab392a0031 firebase_core: a46c312d8bae4defa3d009b2aa7b5b413aeb394e firebase_messaging: e7062cef946e12f93b42abea96937004f8d914d6 FirebaseCore: 28045c1560a2600d284b9c45a904fe322dc890b6 - FirebaseCoreInternal: 6a292e6f0bece1243a737e81556e56e5e19282e3 - FirebaseInstallations: 42d6ead4605d6eafb3b6683674e80e18eb6f2c35 + FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af + FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e FirebaseMessaging: 06c414a21b122396a26847c523d5c370f8325df5 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_app_badger: b87fc231847b03b92ce1412aa351842e7e97932f @@ -350,7 +350,7 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66 + ReachabilitySwift: 2128f3a8c9107e1ad33574c6e58e8285d460b149 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb sensors: 84eb7a30e47a649e4172b71d6e81be614c280336 @@ -359,7 +359,7 @@ SPEC CHECKSUMS: SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812 vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241 volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index eb9213cbf8a..8501191ffe9 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -494,11 +494,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -627,11 +628,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -654,11 +656,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 0.1.0; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Gapopa; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -691,7 +694,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -726,7 +729,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -760,7 +763,7 @@ INFOPLIST_FILE = NotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 90584c3608b..c2e829acd09 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -38,8 +38,6 @@ UIBackgroundModes - fetch - processing remote-notification UILaunchStoryboardName diff --git a/lib/ui/page/support/view.dart b/lib/ui/page/support/view.dart index 5f0b838c147..069680299ca 100644 --- a/lib/ui/page/support/view.dart +++ b/lib/ui/page/support/view.dart @@ -1,3 +1,20 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher_string.dart'; From 7ffbef1c5e6842349b82a77cf3a246a85cc6af12 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 12:44:00 +0300 Subject: [PATCH 34/88] Corrections --- assets/l10n/en-US.ftl | 10 ++++----- assets/l10n/ru-RU.ftl | 10 ++++----- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- ios/Runner/Info.plist | 2 ++ lib/ui/page/auth/view.dart | 24 +++++++++++----------- lib/ui/page/home/controller.dart | 4 ++-- lib/ui/widget/outlined_rounded_button.dart | 2 +- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index 50be1b8285d..90671c198a1 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -323,8 +323,8 @@ err_email_occupied = Specified E-mail is linked to another account. Please, annu err_incorrect_chat_name = Incorrect name err_incorrect_email = Incorrect E-mail. err_incorrect_input = Incorrect input. -err_incorrect_login_input = Unique login should contain only letters of the latin alphabet, numbers and symbols "-", "." and "_". It must start with a letter or number and be at least 3 and max 20 characters long. -err_incorrect_login_or_password = Invalid login or password +err_incorrect_login_input = Login should contain only letters of the latin alphabet, numbers and symbols "-" and "_". It must start with a letter or number and be at least 3 and max 20 characters long. +err_incorrect_login_or_password = Incorrect account identifier or password. err_incorrect_phone = Incorrect phone number. err_input_empty = Must not be empty. err_invalid_crop_coordinates = Invalid crop coordinates @@ -784,7 +784,7 @@ label_introduction_description1 = • you click the button "Sign out". - To save access to your account, please set a password, e-mail or phone number in the{" "} + To save access to your account, please set a password or e-mail number in the{" "} label_introduction_description2 = settings label_introduction_description3 = . label_kb = {$amount} KB @@ -919,7 +919,7 @@ label_reason = Reason label_recent = Recent label_reconnecting_ellipsis = Reconnecting... label_recover_account = Access recovery -label_recover_account_description = Specify your Gapopa ID, login, E-mail or phone number. +label_recover_account_description = Specify your Gapopa ID, login or E-mail. label_recovery_code = Recovery code label_recovery_code_sent = The verification code has been sent to the verified E-mail/phone linked to this account. Please, enter the code below. label_recovery_enter_new_password = Please enter the new password below. @@ -991,7 +991,7 @@ label_set_password = Set password label_settings = Settings label_show_sections = Show sections label_sign_in = Sign in -label_sign_in_input = Gapopa ID, login, E-mail or phone +label_sign_in_input = Gapopa ID, login, E-mail label_sign_in_with_password = Sign in with password label_sign_up = Sign up label_sign_up_code_email_sent = Verification code has been sent to the e-mail {$text} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index a601ad0c0cf..70415f1f0fd 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -324,8 +324,8 @@ err_email_occupied = Указанный E-mail привязан к другом err_incorrect_chat_name = Некорректное имя err_incorrect_email = Некорректный E-mail. err_incorrect_input = Некорректный формат. -err_incorrect_login_input = Уникальный логин должен содержать только буквы латинского алфавита, цифры и символы "-", "." и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. -err_incorrect_login_or_password = Неверный логин или пароль +err_incorrect_login_input = Логин должен содержать только буквы латинского алфавита, цифры и символы "-" и "_". Он должен начинаться с буквы или цифры и содержать не менее 3 и не более 20 символов. +err_incorrect_login_or_password = Некорректный идентификатор аккаунта или пароль. err_incorrect_phone = Некорректный номер телефона. err_input_empty = Поле не должно быть пустым. err_invalid_crop_coordinates = Неверные координаты обрезки @@ -811,7 +811,7 @@ label_introduction_description1 = Чтобы сохранить доступ к аккаунту, пожалуйста, в{" "} label_introduction_description2 = настройках -label_introduction_description3 = {" "}задайте пароль, e-mail или номер телефона. +label_introduction_description3 = {" "}задайте пароль или e-mail. label_kb = {$amount} КБ label_language = Язык label_language_entry = {$code}, {$name} @@ -946,7 +946,7 @@ label_reason = Причина label_recent = Недавние label_reconnecting_ellipsis = Переподключение... label_recover_account = Восстановление доступа -label_recover_account_description = Укажите Ваш Gapopa ID, логин, E-mail или номер телефона. +label_recover_account_description = Укажите Ваш Gapopa ID, логин или E-mail. label_recovery_code = Код восстановления label_recovery_code_sent = Проверочный код отправлен на верифицрованный E-mail/телефон, указанный для данного аккаунта. Пожалуйста, введите код ниже. label_recovery_enter_new_password = Пожалуйста, введите новый пароль ниже. @@ -1019,7 +1019,7 @@ label_set_password = Задать пароль label_settings = Настройки label_show_sections = Показывать разделы label_sign_in = Войти -label_sign_in_input = Gapopa ID, логин, E-mail или номер телефона +label_sign_in_input = Gapopa ID, логин или E-mail label_sign_in_with_password = Войти с паролем label_sign_up = Регистрация label_sign_up_code_email_sent = diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 8501191ffe9..942b0b92205 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -494,7 +494,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 156; + CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -628,7 +628,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 156; + CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -656,7 +656,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 156; + CURRENT_PROJECT_VERSION = 157; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c2e829acd09..cafc90b957c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -57,6 +57,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + ITSAppUsesNonExemptEncryption + UIViewControllerBasedStatusBarAppearance diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index d5410d9111a..01e9c9241bb 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -99,6 +99,18 @@ class AuthView extends StatelessWidget { // Footer part of the page. List footer = [ const SizedBox(height: 25), + OutlinedRoundedButton( + key: const Key('StartButton'), + maxWidth: 210, + height: 46, + leading: Transform.translate( + offset: const Offset(4, 0), + child: const SvgIcon(SvgIcons.guest), + ), + onPressed: c.register, + child: Text('btn_guest'.l10n), + ), + const SizedBox(height: 15), OutlinedRoundedButton( key: const Key('RegisterButton'), maxWidth: 210, @@ -124,18 +136,6 @@ class AuthView extends StatelessWidget { child: Text('btn_sign_in'.l10n), ), const SizedBox(height: 15), - OutlinedRoundedButton( - key: const Key('StartButton'), - maxWidth: 210, - height: 46, - leading: Transform.translate( - offset: const Offset(4, 0), - child: const SvgIcon(SvgIcons.guest), - ), - onPressed: c.register, - child: Text('btn_guest'.l10n), - ), - const SizedBox(height: 15), ]; final Widget column = Column( diff --git a/lib/ui/page/home/controller.dart b/lib/ui/page/home/controller.dart index 09d719546e7..8ba1a76f377 100644 --- a/lib/ui/page/home/controller.dart +++ b/lib/ui/page/home/controller.dart @@ -225,12 +225,12 @@ class HomeController extends GetxController { if (link != null) { stage = IntroductionViewStage.link; + } else if (signedUp) { + stage = IntroductionViewStage.signUp; } else if (!myUser.hasPassword && myUser.emails.confirmed.isEmpty && myUser.phones.confirmed.isEmpty) { stage = IntroductionViewStage.oneTime; - } else if (signedUp) { - stage = IntroductionViewStage.signUp; } if (stage != null) { diff --git a/lib/ui/widget/outlined_rounded_button.dart b/lib/ui/widget/outlined_rounded_button.dart index 5efff8eed91..7a9e3f413a4 100644 --- a/lib/ui/widget/outlined_rounded_button.dart +++ b/lib/ui/widget/outlined_rounded_button.dart @@ -154,7 +154,7 @@ class _OutlinedRoundedButtonState extends State { ? widget.disabled ?? style.colors.secondaryHighlight : _hovered ? Color.alphaBlend( - style.colors.onBackgroundOpacity7, + style.colors.onBackgroundOpacity2, widget.color ?? style.colors.onPrimary, ) : widget.color ?? style.colors.onPrimary, From 9734a730b937aa7ba4e6ee8dc8fa4bdab56c1070 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 18:55:50 +0300 Subject: [PATCH 35/88] Add `Support` button --- assets/icons/menu_help.svg | 1 + assets/l10n/en-US.ftl | 2 +- assets/l10n/ru-RU.ftl | 2 +- lib/l10n/l10n.dart | 1 + lib/routes.dart | 5 +- lib/ui/page/auth/view.dart | 73 +++++++++++++++------ lib/ui/page/home/page/my_profile/view.dart | 3 + lib/ui/page/home/tab/menu/view.dart | 1 + lib/ui/page/home/tab/work/view.dart | 12 ++++ lib/ui/page/login/view.dart | 1 - lib/ui/page/support/view.dart | 7 +- lib/ui/page/work/page/vacancy/view.dart | 4 -- lib/ui/page/work/widget/vacancy_button.dart | 3 - lib/ui/widget/menu_button.dart | 5 +- lib/ui/widget/svg/svgs.dart | 6 ++ 15 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 assets/icons/menu_help.svg diff --git a/assets/icons/menu_help.svg b/assets/icons/menu_help.svg new file mode 100644 index 00000000000..64f0c8a63ec --- /dev/null +++ b/assets/icons/menu_help.svg @@ -0,0 +1 @@ + diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index c42412340e9..8fb132b40b9 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -210,6 +210,7 @@ btn_gallery = Gallery btn_generate_direct_chat_link = Generate btn_gift = Gift btn_guest = Guest +btn_help = Help btn_hide = Hide btn_hide_chat = Hide chat btn_info = Info @@ -787,7 +788,6 @@ label_introduction_description1 = Access to a guest account is maintained for one year or until: • you delete cookies / cache; - • you click the button "Sign out". To save access to your account, please set a password or e-mail number in the{" "} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 44d9b3a27f1..b74f6d64d28 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -210,6 +210,7 @@ btn_gallery = Галерея btn_generate_direct_chat_link = Сгенерировать btn_gift = Подарок btn_guest = Гость +btn_help = Помощь btn_hide = Скрыть btn_hide_chat = Скрыть чат btn_info = Информация @@ -812,7 +813,6 @@ label_introduction_description1 = Доступ к гостевому аккаунту сохраняется в течение одного года или пока: • Вы не удалите куки / кэш; - • Вы не нажмёте кнопку "Выйти". Чтобы сохранить доступ к аккаунту, пожалуйста, в{" "} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7d83ab1d7ff..a29d776eecd 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -307,6 +307,7 @@ extension L10nProfileTabExtension on ProfileTab { ProfileTab.download => 'label_download'.l10n, ProfileTab.danger => 'label_danger_zone'.l10n, ProfileTab.legal => 'label_legal_information'.l10n, + ProfileTab.support => 'btn_help'.l10n, ProfileTab.logout => 'btn_logout'.l10n, }; } diff --git a/lib/routes.dart b/lib/routes.dart index 3e3fc8fcc61..637e381bd17 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -115,7 +115,7 @@ class Routes { enum HomeTab { work, contacts, chats, menu } /// List of [Routes.work] page sections. -enum WorkTab { freelance, frontend, backend, designer } +enum WorkTab { frontend, backend, freelance } /// List of [Routes.me] page sections. enum ProfileTab { @@ -134,6 +134,7 @@ enum ProfileTab { download, danger, legal, + support, logout, } @@ -950,6 +951,8 @@ extension RouteLinks on RouterState { ? this.push : go)('${Routes.work}${tab == null ? '' : '/${tab.name}'}'); + void support({bool push = false}) => (push ? this.push : go)(Routes.support); + /// Changes router location to the [Routes.style] page. /// /// If [push] is `true`, then location is pushed to the router location stack. diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index 01e9c9241bb..9cd6022160c 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -22,6 +22,7 @@ import '/config.dart'; import '/l10n/l10n.dart'; import '/routes.dart'; import '/themes.dart'; +import '/ui/page/home/page/my_profile/language/view.dart'; import '/ui/page/login/controller.dart'; import '/ui/page/login/view.dart'; import '/ui/widget/download_button.dart'; @@ -46,29 +47,59 @@ class AuthView extends StatelessWidget { builder: (AuthController c) { final Widget status = Column( children: [ - if (PlatformUtils.isWeb || !PlatformUtils.isMobile) ...[ - const SizedBox(height: 4), - StyledCupertinoButton( - label: 'btn_download_application'.l10n, - style: style.fonts.normal.regular.secondary, - onPressed: () => _download(context), + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + child: FittedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + StyledCupertinoButton( + label: 'btn_work_with_us'.l10n, + style: style.fonts.small.regular.secondary, + onPressed: () => router.work(null), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + width: 1, + height: 12, + color: style.colors.onBackgroundOpacity20, + ), + if (PlatformUtils.isWeb || !PlatformUtils.isMobile) ...[ + StyledCupertinoButton( + label: 'btn_download'.l10n, + style: style.fonts.small.regular.secondary, + onPressed: () => _download(context), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + width: 1, + height: 12, + color: style.colors.onBackgroundOpacity20, + ), + ], + StyledCupertinoButton( + label: + '${L10n.chosen.value?.locale.languageCode.toUpperCase()}, ${L10n.chosen.value?.name}', + style: style.fonts.small.regular.secondary, + onPressed: () async { + await LanguageSelectionView.show(context, null); + }, + ), + ], + ), + if (Config.copyright.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + Config.copyright, + style: style.fonts.small.regular.secondary, + ), + ], + ], + ), ), - ], - const SizedBox(height: 4), - StyledCupertinoButton( - padding: const EdgeInsets.all(8), - label: 'btn_work_with_us'.l10n, - style: style.fonts.small.regular.secondary, - onPressed: () => router.work(null), ), - const SizedBox(height: 8), - if (Config.copyright.isNotEmpty) ...[ - Text( - Config.copyright, - style: style.fonts.small.regular.secondary, - ), - const SizedBox(height: 8), - ], ], ); diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index aef370f5a04..abe061d4d27 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -320,6 +320,9 @@ class MyProfileView extends StatelessWidget { case ProfileTab.legal: return block(children: [_legal(context, c)]); + case ProfileTab.support: + return const SizedBox(); + case ProfileTab.logout: return const SafeArea( top: false, diff --git a/lib/ui/page/home/tab/menu/view.dart b/lib/ui/page/home/tab/menu/view.dart index 8eca67e5780..e4911996edd 100644 --- a/lib/ui/page/home/tab/menu/view.dart +++ b/lib/ui/page/home/tab/menu/view.dart @@ -165,6 +165,7 @@ class MenuTabView extends StatelessWidget { key: key, inverted: inverted, onPressed: switch (tab) { + ProfileTab.support => router.support, ProfileTab.logout => () async { if (await c.confirmLogout()) { router.go(await c.logout()); diff --git a/lib/ui/page/home/tab/work/view.dart b/lib/ui/page/home/tab/work/view.dart index 5c457e6d549..23a7d886868 100644 --- a/lib/ui/page/home/tab/work/view.dart +++ b/lib/ui/page/home/tab/work/view.dart @@ -24,6 +24,8 @@ import '/themes.dart'; import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/safe_scrollbar.dart'; import '/ui/page/work/widget/vacancy_button.dart'; +import '/ui/widget/animated_button.dart'; +import '/ui/widget/svg/svg.dart'; import 'controller.dart'; /// View of the [HomeTab.work] tab. @@ -73,6 +75,16 @@ class WorkTabView extends StatelessWidget { const SizedBox(width: 16), ], ), + actions: [ + AnimatedButton( + onPressed: router.support, + decorator: (child) => Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 21, 4), + child: child, + ), + child: const SvgIcon(SvgIcons.info), + ) + ], ), body: SafeScrollbar( controller: c.scrollController, diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index c3b5f80c96d..a9a03600451 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -536,7 +536,6 @@ class LoginView extends StatelessWidget { padding: const EdgeInsets.only(left: 1), ), const SizedBox(height: 16), - _terms(context), ]; break; diff --git a/lib/ui/page/support/view.dart b/lib/ui/page/support/view.dart index 5f0b838c147..987a2b8322d 100644 --- a/lib/ui/page/support/view.dart +++ b/lib/ui/page/support/view.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '/config.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; +import '/ui/page/home/page/chat/widget/back_button.dart'; import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/block.dart'; import '/ui/page/work/widget/project_block.dart'; @@ -18,7 +19,11 @@ class SupportView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: CustomAppBar(title: Text('label_support_service'.l10n)), + appBar: CustomAppBar( + leading: const [StyledBackButton()], + title: Text('label_support_service'.l10n), + actions: const [SizedBox(width: 24)], + ), body: ListView( children: [ const ProjectBlock(), diff --git a/lib/ui/page/work/page/vacancy/view.dart b/lib/ui/page/work/page/vacancy/view.dart index e3e38103f15..137748b691b 100644 --- a/lib/ui/page/work/page/vacancy/view.dart +++ b/lib/ui/page/work/page/vacancy/view.dart @@ -19,7 +19,6 @@ import 'package:flutter/material.dart'; import '/routes.dart'; import '/ui/page/work/page/backend/view.dart'; -import '/ui/page/work/page/designer/view.dart'; import '/ui/page/work/page/freelance/view.dart'; import '/ui/page/work/page/frontend/view.dart'; @@ -41,9 +40,6 @@ class VacancyWorkView extends StatelessWidget { case WorkTab.frontend: return const FrontendWorkView(); - - case WorkTab.designer: - return const DesignerWorkView(); } } } diff --git a/lib/ui/page/work/widget/vacancy_button.dart b/lib/ui/page/work/widget/vacancy_button.dart index 957a83dac83..fd8daab54c0 100644 --- a/lib/ui/page/work/widget/vacancy_button.dart +++ b/lib/ui/page/work/widget/vacancy_button.dart @@ -47,19 +47,16 @@ class VacancyWorkButton extends StatelessWidget { WorkTab.backend => 'Backend Developer', WorkTab.frontend => 'Frontend Developer', WorkTab.freelance => 'Freelance', - WorkTab.designer => 'UI/UX Designer', }, subtitle: switch (work) { WorkTab.backend => 'Rust', WorkTab.frontend => 'Flutter/Dart', WorkTab.freelance => 'Flutter/Dart', - WorkTab.designer => 'Figma', }, leading: switch (work) { WorkTab.backend => const SvgIcon(SvgIcons.workRust), WorkTab.frontend => const SvgIcon(SvgIcons.workFlutter), WorkTab.freelance => const SvgIcon(SvgIcons.workFreelance), - WorkTab.designer => const SvgIcon(SvgIcons.workDesigner), }, inverted: selected, onPressed: onPressed == null ? null : () => onPressed?.call(work), diff --git a/lib/ui/widget/menu_button.dart b/lib/ui/widget/menu_button.dart index 227fc48fbb5..03af1145cb7 100644 --- a/lib/ui/widget/menu_button.dart +++ b/lib/ui/widget/menu_button.dart @@ -59,7 +59,8 @@ class MenuButton extends StatelessWidget { ProfileTab.download => 'label_application'.l10n, ProfileTab.danger => 'label_delete_account'.l10n, ProfileTab.legal => 'btn_terms_and_conditions'.l10n, - ProfileTab.logout => 'label_end_session'.l10n, + ProfileTab.support => null, + ProfileTab.logout => null, }, leading = switch (tab) { ProfileTab.public => const SvgIcon(SvgIcons.menuProfile), @@ -78,6 +79,7 @@ class MenuButton extends StatelessWidget { ProfileTab.signing => const SvgIcon(SvgIcons.menuSigning), ProfileTab.storage => const SvgIcon(SvgIcons.menuStorage), ProfileTab.legal => const SvgIcon(SvgIcons.menuLegal), + ProfileTab.support => const SvgIcon(SvgIcons.menuSupport), }, super( key: key ?? @@ -97,6 +99,7 @@ class MenuButton extends StatelessWidget { ProfileTab.download => const Key('Download'), ProfileTab.danger => const Key('DangerZone'), ProfileTab.legal => const Key('Legal'), + ProfileTab.support => const Key('Support'), ProfileTab.logout => const Key('LogoutButton'), }, ); diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index 3d95b1639cd..db8b2c900a0 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1148,6 +1148,12 @@ class SvgIcons { height: 32, ); + static const SvgData menuSupport = SvgData( + 'assets/icons/menu_help.svg', + width: 32, + height: 32, + ); + static const List head = [ SvgData( 'assets/images/logo/head_0.svg', From 0a714dba50be65d490cf8d6271bd763bdf9df972 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:08:07 +0300 Subject: [PATCH 36/88] Corrections --- CHANGELOG.md | 2 + assets/icons/menu_help.svg | 1 + assets/l10n/en-US.ftl | 2 +- assets/l10n/ru-RU.ftl | 2 +- lib/l10n/l10n.dart | 1 + lib/routes.dart | 6 +- lib/ui/page/auth/view.dart | 73 +++++++++++++++------ lib/ui/page/home/page/my_profile/view.dart | 3 + lib/ui/page/home/tab/menu/view.dart | 6 +- lib/ui/page/home/tab/work/view.dart | 12 ++++ lib/ui/page/login/view.dart | 7 +- lib/ui/page/support/view.dart | 7 +- lib/ui/page/work/page/vacancy/view.dart | 4 -- lib/ui/page/work/widget/vacancy_button.dart | 3 - lib/ui/widget/menu_button.dart | 5 +- lib/ui/widget/svg/svgs.dart | 6 ++ 16 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 assets/icons/menu_help.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dbae5dfe9..99c5ed2aa88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All user visible changes to this project will be documented in this file. This p - UI: - Home page: - Contacts button moved to chats tab app bar. ([#970]) + - Work page: + - Removed UI/UX designer vacancy. ([#971]) [#970]: /../../pull/970 [#971]: /../../pull/971 diff --git a/assets/icons/menu_help.svg b/assets/icons/menu_help.svg new file mode 100644 index 00000000000..64f0c8a63ec --- /dev/null +++ b/assets/icons/menu_help.svg @@ -0,0 +1 @@ + diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index 90671c198a1..a2a4929520c 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -210,6 +210,7 @@ btn_gallery = Gallery btn_generate_direct_chat_link = Generate btn_gift = Gift btn_guest = Guest +btn_help = Help btn_hide = Hide btn_hide_chat = Hide chat btn_info = Info @@ -781,7 +782,6 @@ label_introduction_description1 = Access to a guest account is maintained for one year or until: • you delete cookies / cache; - • you click the button "Sign out". To save access to your account, please set a password or e-mail number in the{" "} diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 70415f1f0fd..b4cf63cfb25 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -210,6 +210,7 @@ btn_gallery = Галерея btn_generate_direct_chat_link = Сгенерировать btn_gift = Подарок btn_guest = Гость +btn_help = Помощь btn_hide = Скрыть btn_hide_chat = Скрыть чат btn_info = Информация @@ -806,7 +807,6 @@ label_introduction_description1 = Доступ к гостевому аккаунту сохраняется в течение одного года или пока: • Вы не удалите куки / кэш; - • Вы не нажмёте кнопку "Выйти". Чтобы сохранить доступ к аккаунту, пожалуйста, в{" "} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7d83ab1d7ff..a29d776eecd 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -307,6 +307,7 @@ extension L10nProfileTabExtension on ProfileTab { ProfileTab.download => 'label_download'.l10n, ProfileTab.danger => 'label_danger_zone'.l10n, ProfileTab.legal => 'label_legal_information'.l10n, + ProfileTab.support => 'btn_help'.l10n, ProfileTab.logout => 'btn_logout'.l10n, }; } diff --git a/lib/routes.dart b/lib/routes.dart index 3e3fc8fcc61..2283243addc 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -115,7 +115,7 @@ class Routes { enum HomeTab { work, contacts, chats, menu } /// List of [Routes.work] page sections. -enum WorkTab { freelance, frontend, backend, designer } +enum WorkTab { frontend, backend, freelance } /// List of [Routes.me] page sections. enum ProfileTab { @@ -134,6 +134,7 @@ enum ProfileTab { download, danger, legal, + support, logout, } @@ -950,6 +951,9 @@ extension RouteLinks on RouterState { ? this.push : go)('${Routes.work}${tab == null ? '' : '/${tab.name}'}'); + /// Changes router location to the [Routes.support] page. + void support({bool push = false}) => (push ? this.push : go)(Routes.support); + /// Changes router location to the [Routes.style] page. /// /// If [push] is `true`, then location is pushed to the router location stack. diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index 01e9c9241bb..9cd6022160c 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -22,6 +22,7 @@ import '/config.dart'; import '/l10n/l10n.dart'; import '/routes.dart'; import '/themes.dart'; +import '/ui/page/home/page/my_profile/language/view.dart'; import '/ui/page/login/controller.dart'; import '/ui/page/login/view.dart'; import '/ui/widget/download_button.dart'; @@ -46,29 +47,59 @@ class AuthView extends StatelessWidget { builder: (AuthController c) { final Widget status = Column( children: [ - if (PlatformUtils.isWeb || !PlatformUtils.isMobile) ...[ - const SizedBox(height: 4), - StyledCupertinoButton( - label: 'btn_download_application'.l10n, - style: style.fonts.normal.regular.secondary, - onPressed: () => _download(context), + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + child: FittedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + StyledCupertinoButton( + label: 'btn_work_with_us'.l10n, + style: style.fonts.small.regular.secondary, + onPressed: () => router.work(null), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + width: 1, + height: 12, + color: style.colors.onBackgroundOpacity20, + ), + if (PlatformUtils.isWeb || !PlatformUtils.isMobile) ...[ + StyledCupertinoButton( + label: 'btn_download'.l10n, + style: style.fonts.small.regular.secondary, + onPressed: () => _download(context), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + width: 1, + height: 12, + color: style.colors.onBackgroundOpacity20, + ), + ], + StyledCupertinoButton( + label: + '${L10n.chosen.value?.locale.languageCode.toUpperCase()}, ${L10n.chosen.value?.name}', + style: style.fonts.small.regular.secondary, + onPressed: () async { + await LanguageSelectionView.show(context, null); + }, + ), + ], + ), + if (Config.copyright.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + Config.copyright, + style: style.fonts.small.regular.secondary, + ), + ], + ], + ), ), - ], - const SizedBox(height: 4), - StyledCupertinoButton( - padding: const EdgeInsets.all(8), - label: 'btn_work_with_us'.l10n, - style: style.fonts.small.regular.secondary, - onPressed: () => router.work(null), ), - const SizedBox(height: 8), - if (Config.copyright.isNotEmpty) ...[ - Text( - Config.copyright, - style: style.fonts.small.regular.secondary, - ), - const SizedBox(height: 8), - ], ], ); diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index aef370f5a04..abe061d4d27 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -320,6 +320,9 @@ class MyProfileView extends StatelessWidget { case ProfileTab.legal: return block(children: [_legal(context, c)]); + case ProfileTab.support: + return const SizedBox(); + case ProfileTab.logout: return const SafeArea( top: false, diff --git a/lib/ui/page/home/tab/menu/view.dart b/lib/ui/page/home/tab/menu/view.dart index 8eca67e5780..b193b506525 100644 --- a/lib/ui/page/home/tab/menu/view.dart +++ b/lib/ui/page/home/tab/menu/view.dart @@ -163,8 +163,12 @@ class MenuTabView extends StatelessWidget { child: MenuButton.tab( tab, key: key, - inverted: inverted, + inverted: switch (tab) { + ProfileTab.support => router.route == Routes.support, + (_) => inverted, + }, onPressed: switch (tab) { + ProfileTab.support => router.support, ProfileTab.logout => () async { if (await c.confirmLogout()) { router.go(await c.logout()); diff --git a/lib/ui/page/home/tab/work/view.dart b/lib/ui/page/home/tab/work/view.dart index 5c457e6d549..23a7d886868 100644 --- a/lib/ui/page/home/tab/work/view.dart +++ b/lib/ui/page/home/tab/work/view.dart @@ -24,6 +24,8 @@ import '/themes.dart'; import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/safe_scrollbar.dart'; import '/ui/page/work/widget/vacancy_button.dart'; +import '/ui/widget/animated_button.dart'; +import '/ui/widget/svg/svg.dart'; import 'controller.dart'; /// View of the [HomeTab.work] tab. @@ -73,6 +75,16 @@ class WorkTabView extends StatelessWidget { const SizedBox(width: 16), ], ), + actions: [ + AnimatedButton( + onPressed: router.support, + decorator: (child) => Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 21, 4), + child: child, + ), + child: const SvgIcon(SvgIcons.info), + ) + ], ), body: SafeScrollbar( controller: c.scrollController, diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index ec52ebf9bbd..7b04dec2456 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -267,6 +267,7 @@ class LoginView extends StatelessWidget { state: c.email, label: 'label_email'.l10n, hint: 'example@domain.com', + floatingLabelBehavior: FloatingLabelBehavior.always, style: style.fonts.normal.regular.onBackground, treatErrorAsStatus: false, ), @@ -337,7 +338,10 @@ class LoginView extends StatelessWidget { ? SvgIcons.visibleOff : SvgIcons.visibleOn, ), - subtitle: WidgetButton( + ), + Padding( + padding: const EdgeInsets.fromLTRB(21, 8, 8, 8), + child: WidgetButton( onPressed: () { c.recovery.clear(); c.recoveryCode.clear(); @@ -389,7 +393,6 @@ class LoginView extends StatelessWidget { padding: const EdgeInsets.only(left: 1), ), const SizedBox(height: 16), - _terms(context), ]; break; diff --git a/lib/ui/page/support/view.dart b/lib/ui/page/support/view.dart index 069680299ca..be5a3a1ebb5 100644 --- a/lib/ui/page/support/view.dart +++ b/lib/ui/page/support/view.dart @@ -22,6 +22,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '/config.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; +import '/ui/page/home/page/chat/widget/back_button.dart'; import '/ui/page/home/widget/app_bar.dart'; import '/ui/page/home/widget/block.dart'; import '/ui/page/work/widget/project_block.dart'; @@ -35,7 +36,11 @@ class SupportView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: CustomAppBar(title: Text('label_support_service'.l10n)), + appBar: CustomAppBar( + leading: const [StyledBackButton()], + title: Text('label_support_service'.l10n), + actions: const [SizedBox(width: 24)], + ), body: ListView( children: [ const ProjectBlock(), diff --git a/lib/ui/page/work/page/vacancy/view.dart b/lib/ui/page/work/page/vacancy/view.dart index e3e38103f15..137748b691b 100644 --- a/lib/ui/page/work/page/vacancy/view.dart +++ b/lib/ui/page/work/page/vacancy/view.dart @@ -19,7 +19,6 @@ import 'package:flutter/material.dart'; import '/routes.dart'; import '/ui/page/work/page/backend/view.dart'; -import '/ui/page/work/page/designer/view.dart'; import '/ui/page/work/page/freelance/view.dart'; import '/ui/page/work/page/frontend/view.dart'; @@ -41,9 +40,6 @@ class VacancyWorkView extends StatelessWidget { case WorkTab.frontend: return const FrontendWorkView(); - - case WorkTab.designer: - return const DesignerWorkView(); } } } diff --git a/lib/ui/page/work/widget/vacancy_button.dart b/lib/ui/page/work/widget/vacancy_button.dart index 957a83dac83..fd8daab54c0 100644 --- a/lib/ui/page/work/widget/vacancy_button.dart +++ b/lib/ui/page/work/widget/vacancy_button.dart @@ -47,19 +47,16 @@ class VacancyWorkButton extends StatelessWidget { WorkTab.backend => 'Backend Developer', WorkTab.frontend => 'Frontend Developer', WorkTab.freelance => 'Freelance', - WorkTab.designer => 'UI/UX Designer', }, subtitle: switch (work) { WorkTab.backend => 'Rust', WorkTab.frontend => 'Flutter/Dart', WorkTab.freelance => 'Flutter/Dart', - WorkTab.designer => 'Figma', }, leading: switch (work) { WorkTab.backend => const SvgIcon(SvgIcons.workRust), WorkTab.frontend => const SvgIcon(SvgIcons.workFlutter), WorkTab.freelance => const SvgIcon(SvgIcons.workFreelance), - WorkTab.designer => const SvgIcon(SvgIcons.workDesigner), }, inverted: selected, onPressed: onPressed == null ? null : () => onPressed?.call(work), diff --git a/lib/ui/widget/menu_button.dart b/lib/ui/widget/menu_button.dart index 227fc48fbb5..03af1145cb7 100644 --- a/lib/ui/widget/menu_button.dart +++ b/lib/ui/widget/menu_button.dart @@ -59,7 +59,8 @@ class MenuButton extends StatelessWidget { ProfileTab.download => 'label_application'.l10n, ProfileTab.danger => 'label_delete_account'.l10n, ProfileTab.legal => 'btn_terms_and_conditions'.l10n, - ProfileTab.logout => 'label_end_session'.l10n, + ProfileTab.support => null, + ProfileTab.logout => null, }, leading = switch (tab) { ProfileTab.public => const SvgIcon(SvgIcons.menuProfile), @@ -78,6 +79,7 @@ class MenuButton extends StatelessWidget { ProfileTab.signing => const SvgIcon(SvgIcons.menuSigning), ProfileTab.storage => const SvgIcon(SvgIcons.menuStorage), ProfileTab.legal => const SvgIcon(SvgIcons.menuLegal), + ProfileTab.support => const SvgIcon(SvgIcons.menuSupport), }, super( key: key ?? @@ -97,6 +99,7 @@ class MenuButton extends StatelessWidget { ProfileTab.download => const Key('Download'), ProfileTab.danger => const Key('DangerZone'), ProfileTab.legal => const Key('Legal'), + ProfileTab.support => const Key('Support'), ProfileTab.logout => const Key('LogoutButton'), }, ); diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index 3d95b1639cd..db8b2c900a0 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1148,6 +1148,12 @@ class SvgIcons { height: 32, ); + static const SvgData menuSupport = SvgData( + 'assets/icons/menu_help.svg', + width: 32, + height: 32, + ); + static const List head = [ SvgData( 'assets/images/logo/head_0.svg', From cc5533dab92accc4ae67ee0949b425b74fcf2f89 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:09:08 +0300 Subject: [PATCH 37/88] Bump up version --- CHANGELOG.md | 6 +++--- helm/messenger/Chart.yaml | 2 +- pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c5ed2aa88..77f1cccc03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ All user visible changes to this project will be documented in this file. This p -## [0.1.0-alpha.14] · 2024-??-?? -[0.1.0-alpha.14]: /../../tree/v0.1.0-alpha.14 +## [0.1.0-alpha.13.4] · 2024-04-22 +[0.1.0-alpha.13.4]: /../../tree/v0.1.0-alpha.13.4 -[Diff](/../../compare/v0.1.0-alpha.13.3...v0.1.0-alpha.14) | [Milestone](/../../milestone/22) +[Diff](/../../compare/v0.1.0-alpha.13.3...v0.1.0-alpha.13.4) | [Milestone](/../../milestone/22) ### Added diff --git a/helm/messenger/Chart.yaml b/helm/messenger/Chart.yaml index 9267a8bf684..41e44d6073d 100644 --- a/helm/messenger/Chart.yaml +++ b/helm/messenger/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: messenger description: Open-source front-end part of messenger by team113. version: 0.1.2 -appVersion: 0.1.0-alpha.13.3 +appVersion: 0.1.0-alpha.13.4 type: application sources: - https://github.com/team113/messenger diff --git a/pubspec.yaml b/pubspec.yaml index 34b98ea25ae..00114b526b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: messenger -version: 0.1.0-alpha.13.3 +version: 0.1.0-alpha.13.4 publish_to: none environment: From 33d14e05bdcdcd8ae6b9d9ec69829da13bdd7bf4 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:11:47 +0300 Subject: [PATCH 38/88] Add more CHANGELOG entries --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f1cccc03e..aba213f24c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All user visible changes to this project will be documented in this file. This p - UI: - Home page: - Contacts button moved to chats tab app bar. ([#970]) + - Auth page: + - Redesigned footer. ([#971]) - Work page: - Removed UI/UX designer vacancy. ([#971]) From 3be01617b807ed4f2809c23ec091ab3b2d213e1d Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:16:39 +0300 Subject: [PATCH 39/88] Use `label_language_entry` for l10n --- lib/ui/page/auth/view.dart | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index 9cd6022160c..b390fd37fc5 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -79,14 +79,20 @@ class AuthView extends StatelessWidget { color: style.colors.onBackgroundOpacity20, ), ], - StyledCupertinoButton( - label: - '${L10n.chosen.value?.locale.languageCode.toUpperCase()}, ${L10n.chosen.value?.name}', - style: style.fonts.small.regular.secondary, - onPressed: () async { - await LanguageSelectionView.show(context, null); - }, - ), + Obx(() { + final Language? chosen = L10n.chosen.value; + + return StyledCupertinoButton( + label: 'label_language_entry'.l10nfmt({ + 'code': chosen?.locale.languageCode.toUpperCase(), + 'name': chosen?.name, + }), + style: style.fonts.small.regular.secondary, + onPressed: () async { + await LanguageSelectionView.show(context, null); + }, + ); + }), ], ), if (Config.copyright.isNotEmpty) ...[ From cca720213e2eabd71ecddad23fa0a48986d63e4c Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:27:06 +0300 Subject: [PATCH 40/88] Remove designer page at all --- .../page/work/page/designer/controller.dart | 78 ------------ lib/ui/page/work/page/designer/view.dart | 120 ------------------ 2 files changed, 198 deletions(-) delete mode 100644 lib/ui/page/work/page/designer/controller.dart delete mode 100644 lib/ui/page/work/page/designer/view.dart diff --git a/lib/ui/page/work/page/designer/controller.dart b/lib/ui/page/work/page/designer/controller.dart deleted file mode 100644 index 29d72709cff..00000000000 --- a/lib/ui/page/work/page/designer/controller.dart +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:get/get.dart'; - -import '/domain/model/chat_item.dart'; -import '/domain/model/user.dart'; -import '/domain/service/auth.dart'; -import '/l10n/l10n.dart'; -import '/provider/gql/exceptions.dart' show UseChatDirectLinkException; -import '/routes.dart'; -import '/util/message_popup.dart'; - -/// Controller of the [WorkTab.designer] section of [Routes.work] page. -class DesignerWorkController extends GetxController { - DesignerWorkController(this._authService); - - /// [RxStatus] of the [useLink] being invoked. - /// - /// May be: - /// - `status.isEmpty`, meaning [useLink] isn't in progress. - /// - `status.isLoading`, meaning [useLink] is fetching its data. - final Rx linkStatus = Rx(RxStatus.empty()); - - /// [AuthService] for using the [_link]. - final AuthService _authService; - - /// [ChatDirectLinkSlug] to use in the [useLink]. - static const ChatDirectLinkSlug _link = - ChatDirectLinkSlug.unchecked('HR-Gapopa'); - - // TODO: Remove when backend supports it out of the box. - /// Welcome message of the [Chat] in the [_link]. - static final ChatMessageText _welcome = - ChatMessageText('label_welcome_message_vacancy'.l10n); - - /// Returns the authorization [RxStatus]. - Rx get status => _authService.status; - - /// Uses the [ChatDirectLinkSlug]. - Future useLink({bool? signedUp}) async { - linkStatus.value = RxStatus.loading(); - - try { - if (status.value.isEmpty) { - router.home(signedUp: signedUp); - } - - router.chat( - await _authService.useChatDirectLink(_link), - welcome: _welcome, - ); - - linkStatus.value = RxStatus.empty(); - } on UseChatDirectLinkException catch (e) { - linkStatus.value = RxStatus.empty(); - MessagePopup.error(e.toMessage()); - } catch (e) { - linkStatus.value = RxStatus.empty(); - MessagePopup.error(e); - rethrow; - } - } -} diff --git a/lib/ui/page/work/page/designer/view.dart b/lib/ui/page/work/page/designer/view.dart deleted file mode 100644 index 087e9fe8cb4..00000000000 --- a/lib/ui/page/work/page/designer/view.dart +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -import '/config.dart'; -import '/l10n/l10n.dart'; -import '/routes.dart'; -import '/themes.dart'; -import '/ui/page/home/page/chat/widget/back_button.dart'; -import '/ui/page/home/widget/app_bar.dart'; -import '/ui/page/home/widget/block.dart'; -import '/ui/page/login/controller.dart'; -import '/ui/page/login/view.dart'; -import '/ui/page/work/widget/proceed_block.dart'; -import '/ui/page/work/widget/project_block.dart'; -import '/ui/page/work/widget/share_icon_button.dart'; -import '/ui/widget/widget_button.dart'; -import '/util/platform_utils.dart'; -import 'controller.dart'; - -/// View of [WorkTab.designer] section of [Routes.work] page. -class DesignerWorkView extends StatelessWidget { - const DesignerWorkView({super.key}); - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).style; - - return GetBuilder( - init: DesignerWorkController(Get.find()), - builder: (DesignerWorkController c) { - return Scaffold( - appBar: CustomAppBar( - title: const Text('UI/UX Designer'), - leading: const [StyledBackButton()], - actions: [ShareIconButton('${Config.origin}${router.route}')], - ), - body: Center( - child: ListView( - shrinkWrap: !context.isNarrow, - children: [ - const SizedBox(height: 4), - const ProjectBlock(), - Block( - title: 'label_conditions'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_conditions_ui_ux_designer'.l10n)], - ), - Block( - title: 'label_requirements'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_requirements_ui_ux_developer'.l10n)], - ), - Block( - title: 'label_tech_stack'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_tech_stack_ui_ux_designer'.l10n)], - ), - Block( - title: 'label_source_code'.l10n, - children: [ - Row( - children: [ - WidgetButton( - onPressed: () => launchUrlString( - '${Config.origin}${Routes.style}', - ), - child: Text( - '- Styles page (UI-kit)', - style: style.fonts.normal.regular.primary, - ), - ), - ], - ), - ], - ), - Obx(() { - return ProceedBlock( - 'btn_schedule_an_interview'.l10n, - onPressed: c.linkStatus.value.isLoading - ? null - : () async { - if (c.status.value.isSuccess) { - await c.useLink(); - } else { - await LoginView.show( - context, - initial: LoginViewStage.signUpOrSignIn, - onSuccess: c.useLink, - ); - } - }, - ); - }), - const SizedBox(height: 4), - ], - ), - ), - ); - }, - ); - } -} From 56c409dfb5cd94d881550209c14b0605f4a43572 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 22 Apr 2024 19:27:38 +0300 Subject: [PATCH 41/88] Remove designer page --- .../page/work/page/designer/controller.dart | 78 ------------ lib/ui/page/work/page/designer/view.dart | 120 ------------------ 2 files changed, 198 deletions(-) delete mode 100644 lib/ui/page/work/page/designer/controller.dart delete mode 100644 lib/ui/page/work/page/designer/view.dart diff --git a/lib/ui/page/work/page/designer/controller.dart b/lib/ui/page/work/page/designer/controller.dart deleted file mode 100644 index 29d72709cff..00000000000 --- a/lib/ui/page/work/page/designer/controller.dart +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:get/get.dart'; - -import '/domain/model/chat_item.dart'; -import '/domain/model/user.dart'; -import '/domain/service/auth.dart'; -import '/l10n/l10n.dart'; -import '/provider/gql/exceptions.dart' show UseChatDirectLinkException; -import '/routes.dart'; -import '/util/message_popup.dart'; - -/// Controller of the [WorkTab.designer] section of [Routes.work] page. -class DesignerWorkController extends GetxController { - DesignerWorkController(this._authService); - - /// [RxStatus] of the [useLink] being invoked. - /// - /// May be: - /// - `status.isEmpty`, meaning [useLink] isn't in progress. - /// - `status.isLoading`, meaning [useLink] is fetching its data. - final Rx linkStatus = Rx(RxStatus.empty()); - - /// [AuthService] for using the [_link]. - final AuthService _authService; - - /// [ChatDirectLinkSlug] to use in the [useLink]. - static const ChatDirectLinkSlug _link = - ChatDirectLinkSlug.unchecked('HR-Gapopa'); - - // TODO: Remove when backend supports it out of the box. - /// Welcome message of the [Chat] in the [_link]. - static final ChatMessageText _welcome = - ChatMessageText('label_welcome_message_vacancy'.l10n); - - /// Returns the authorization [RxStatus]. - Rx get status => _authService.status; - - /// Uses the [ChatDirectLinkSlug]. - Future useLink({bool? signedUp}) async { - linkStatus.value = RxStatus.loading(); - - try { - if (status.value.isEmpty) { - router.home(signedUp: signedUp); - } - - router.chat( - await _authService.useChatDirectLink(_link), - welcome: _welcome, - ); - - linkStatus.value = RxStatus.empty(); - } on UseChatDirectLinkException catch (e) { - linkStatus.value = RxStatus.empty(); - MessagePopup.error(e.toMessage()); - } catch (e) { - linkStatus.value = RxStatus.empty(); - MessagePopup.error(e); - rethrow; - } - } -} diff --git a/lib/ui/page/work/page/designer/view.dart b/lib/ui/page/work/page/designer/view.dart deleted file mode 100644 index 087e9fe8cb4..00000000000 --- a/lib/ui/page/work/page/designer/view.dart +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - -import '/config.dart'; -import '/l10n/l10n.dart'; -import '/routes.dart'; -import '/themes.dart'; -import '/ui/page/home/page/chat/widget/back_button.dart'; -import '/ui/page/home/widget/app_bar.dart'; -import '/ui/page/home/widget/block.dart'; -import '/ui/page/login/controller.dart'; -import '/ui/page/login/view.dart'; -import '/ui/page/work/widget/proceed_block.dart'; -import '/ui/page/work/widget/project_block.dart'; -import '/ui/page/work/widget/share_icon_button.dart'; -import '/ui/widget/widget_button.dart'; -import '/util/platform_utils.dart'; -import 'controller.dart'; - -/// View of [WorkTab.designer] section of [Routes.work] page. -class DesignerWorkView extends StatelessWidget { - const DesignerWorkView({super.key}); - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).style; - - return GetBuilder( - init: DesignerWorkController(Get.find()), - builder: (DesignerWorkController c) { - return Scaffold( - appBar: CustomAppBar( - title: const Text('UI/UX Designer'), - leading: const [StyledBackButton()], - actions: [ShareIconButton('${Config.origin}${router.route}')], - ), - body: Center( - child: ListView( - shrinkWrap: !context.isNarrow, - children: [ - const SizedBox(height: 4), - const ProjectBlock(), - Block( - title: 'label_conditions'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_conditions_ui_ux_designer'.l10n)], - ), - Block( - title: 'label_requirements'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_requirements_ui_ux_developer'.l10n)], - ), - Block( - title: 'label_tech_stack'.l10n, - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text('label_tech_stack_ui_ux_designer'.l10n)], - ), - Block( - title: 'label_source_code'.l10n, - children: [ - Row( - children: [ - WidgetButton( - onPressed: () => launchUrlString( - '${Config.origin}${Routes.style}', - ), - child: Text( - '- Styles page (UI-kit)', - style: style.fonts.normal.regular.primary, - ), - ), - ], - ), - ], - ), - Obx(() { - return ProceedBlock( - 'btn_schedule_an_interview'.l10n, - onPressed: c.linkStatus.value.isLoading - ? null - : () async { - if (c.status.value.isSuccess) { - await c.useLink(); - } else { - await LoginView.show( - context, - initial: LoginViewStage.signUpOrSignIn, - onSuccess: c.useLink, - ); - } - }, - ); - }), - const SizedBox(height: 4), - ], - ), - ), - ); - }, - ); - } -} From 7a0a0d7ceb8ed85928b9ef3ce6ff6698bf310c47 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 23 Apr 2024 08:52:47 +0300 Subject: [PATCH 42/88] Bump up date --- CHANGELOG.md | 2 +- pubspec.lock | 32 -------------------------------- pubspec.yaml | 3 --- 3 files changed, 1 insertion(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aba213f24c8..b5bc828679c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All user visible changes to this project will be documented in this file. This p -## [0.1.0-alpha.13.4] · 2024-04-22 +## [0.1.0-alpha.13.4] · 2024-04-23 [0.1.0-alpha.13.4]: /../../tree/v0.1.0-alpha.13.4 [Diff](/../../compare/v0.1.0-alpha.13.3...v0.1.0-alpha.13.4) | [Milestone](/../../milestone/22) diff --git a/pubspec.lock b/pubspec.lock index 9b728dfd2b8..84ca2f90aef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,38 +472,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - flutter_background_service: - dependency: "direct main" - description: - name: flutter_background_service - sha256: "7b9f15ab7d1a5784850ecfde722be4ace4dbcf1072a1fe33b7f9cbe871241e53" - url: "https://pub.dev" - source: hosted - version: "2.4.6" - flutter_background_service_android: - dependency: "direct main" - description: - name: flutter_background_service_android - sha256: "57fd631d800f7e56ca5bfadcfc00c8903eb721b23506b1a387342039521883ec" - url: "https://pub.dev" - source: hosted - version: "3.0.3" - flutter_background_service_ios: - dependency: "direct main" - description: - name: flutter_background_service_ios - sha256: d3f1dc009137c1ac19f5e3d88668836f088a7e078396733547339765d46ff12f - url: "https://pub.dev" - source: hosted - version: "2.4.0" - flutter_background_service_platform_interface: - dependency: transitive - description: - name: flutter_background_service_platform_interface - sha256: "5f0480ee8378984f718d640abbe09b4d6d024d58a72b2d8b86bdedb3cf095ff8" - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter_callkit_incoming: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 00114b526b2..627759505c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,9 +32,6 @@ dependencies: sdk: flutter flutter_animate: ^4.5.0 flutter_app_badger: ^1.5.0 - flutter_background_service: ^2.4.6 - flutter_background_service_android: ^3.0.3 - flutter_background_service_ios: ^2.4.0 flutter_callkit_incoming: ^2.0.3 flutter_custom_cursor: ^0.0.4 flutter_list_view: From fbc5a902886bf002136c50bfe48da7795e88f193 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 23 Apr 2024 14:41:24 +0300 Subject: [PATCH 43/88] Replace callkit package --- assets/icons/menu_help.svg | 2 +- ios/Podfile.lock | 16 ----------- lib/ui/page/auth/view.dart | 2 +- .../page/home/page/chat/info/controller.dart | 27 ++++++++++++++++++- lib/ui/page/home/page/user/controller.dart | 27 ++++++++++++++++++- pubspec.lock | 11 ++++---- pubspec.yaml | 3 ++- 7 files changed, 62 insertions(+), 26 deletions(-) diff --git a/assets/icons/menu_help.svg b/assets/icons/menu_help.svg index 64f0c8a63ec..bc6a2b7a9f3 100644 --- a/assets/icons/menu_help.svg +++ b/assets/icons/menu_help.svg @@ -1 +1 @@ - + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 62a87d85e7b..80c7bdf88f9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,7 +6,6 @@ PODS: - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift - - CryptoSwift (1.8.2) - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.8): @@ -78,11 +77,6 @@ PODS: - Flutter (1.0.0) - flutter_app_badger (1.3.0): - Flutter - - flutter_background_service_ios (0.0.3): - - Flutter - - flutter_callkit_incoming (0.0.1): - - CryptoSwift - - Flutter - flutter_local_notifications (0.0.1): - Flutter - GoogleDataTransport (9.4.1): @@ -192,8 +186,6 @@ DEPENDENCIES: - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`) - - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - image_gallery_saver (from `.symlinks/plugins/image_gallery_saver/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -220,7 +212,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - CryptoSwift - DKImagePickerController - DKPhotoGallery - Firebase @@ -259,10 +250,6 @@ EXTERNAL SOURCES: :path: Flutter flutter_app_badger: :path: ".symlinks/plugins/flutter_app_badger/ios" - flutter_background_service_ios: - :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_callkit_incoming: - :path: ".symlinks/plugins/flutter_callkit_incoming/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" image_gallery_saver: @@ -314,7 +301,6 @@ SPEC CHECKSUMS: all_sensors: 13f46502204bebbd1e06b65ff3d7cdaa03d88b26 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - CryptoSwift: c63a805d8bb5e5538e88af4e44bb537776af11ea device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 @@ -328,8 +314,6 @@ SPEC CHECKSUMS: FirebaseMessaging: 06c414a21b122396a26847c523d5c370f8325df5 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_app_badger: b87fc231847b03b92ce1412aa351842e7e97932f - flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac - flutter_callkit_incoming: 417dd1b46541cdd5d855ad795ccbe97d1c18155e flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 diff --git a/lib/ui/page/auth/view.dart b/lib/ui/page/auth/view.dart index b390fd37fc5..027ada2c08e 100644 --- a/lib/ui/page/auth/view.dart +++ b/lib/ui/page/auth/view.dart @@ -217,7 +217,7 @@ class AuthView extends StatelessWidget { const SizedBox(height: 8), Expanded(child: Center(child: column)), const SizedBox(height: 8), - status, + SafeArea(top: false, child: status), ], ), ), diff --git a/lib/ui/page/home/page/chat/info/controller.dart b/lib/ui/page/home/page/chat/info/controller.dart index 29cc442b074..b7f60393dd3 100644 --- a/lib/ui/page/home/page/chat/info/controller.dart +++ b/lib/ui/page/home/page/chat/info/controller.dart @@ -23,7 +23,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '/config.dart'; import '/domain/model/chat.dart'; import '/domain/model/my_user.dart'; import '/domain/model/native_file.dart'; @@ -325,9 +327,32 @@ class ChatInfoController extends GetxController { } } + // TODO: Replace with GraphQL mutation when implemented. /// Reports the [chat]. Future reportChat() async { - // TODO: Implement. + String? encodeQueryParameters(Map params) { + return params.entries + .map((e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + } + + try { + await launchUrl( + Uri( + scheme: 'mailto', + path: Config.support, + query: encodeQueryParameters({ + 'subject': '[App] Report on ChatId($chatId)', + 'body': '${reporting.text}\n\n', + }), + ), + ); + } catch (e) { + await MessagePopup.error('label_contact_us_via_provided_email'.l10nfmt({ + 'email': Config.support, + })); + } } /// Clears all the [ChatItem]s of the [chat]. diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index 047174eecae..f906c7ffc19 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -22,8 +22,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; import '/api/backend/schema.dart' show Presence; +import '/config.dart'; import '/domain/model/chat.dart'; import '/domain/model/contact.dart'; import '/domain/model/mute_duration.dart'; @@ -306,9 +308,32 @@ class UserController extends GetxController { } } + // TODO: Replace with GraphQL mutation when implemented. /// Reports the [user]. Future report() async { - // TODO: Implement. + String? encodeQueryParameters(Map params) { + return params.entries + .map((e) => + '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + } + + try { + await launchUrl( + Uri( + scheme: 'mailto', + path: Config.support, + query: encodeQueryParameters({ + 'subject': '[App] Report on UserId($id)', + 'body': '${reporting.text}\n\n', + }), + ), + ); + } catch (e) { + await MessagePopup.error('label_contact_us_via_provided_email'.l10nfmt({ + 'email': Config.support, + })); + } } /// Removes the [user] from the blocklist of the authenticated [MyUser]. diff --git a/pubspec.lock b/pubspec.lock index 84ca2f90aef..48c4522e016 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -475,11 +475,12 @@ packages: flutter_callkit_incoming: dependency: "direct main" description: - name: flutter_callkit_incoming - sha256: "877363b53651457059f6d6adac2bcb659f05dd03741a816145b5778bd4eb6ac0" - url: "https://pub.dev" - source: hosted - version: "2.0.3" + path: "." + ref: HEAD + resolved-ref: c8310b00a27eb5e49329ee6e37fa79f0b306113d + url: "https://github.com/SleepySquash/flutter_callkit_incoming_android" + source: git + version: "2.0.4" flutter_custom_cursor: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 627759505c2..15d50ff3e4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,8 @@ dependencies: sdk: flutter flutter_animate: ^4.5.0 flutter_app_badger: ^1.5.0 - flutter_callkit_incoming: ^2.0.3 + flutter_callkit_incoming: + git: https://github.com/SleepySquash/flutter_callkit_incoming_android flutter_custom_cursor: ^0.0.4 flutter_list_view: git: https://github.com/krida2000/flutter_list_view.git From 69be95859581ac7f53cafcc1b649dea7355d37f6 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 23 Apr 2024 14:47:40 +0300 Subject: [PATCH 44/88] Bump up version --- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 942b0b92205..a2d0899af98 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -494,7 +494,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -628,7 +628,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -656,7 +656,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CURRENT_PROJECT_VERSION = 157; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5XY57W3XYG; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; From 50e16cd009322e2cbe8b8390fa8ff46a1389428b Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 23 Apr 2024 17:57:32 +0300 Subject: [PATCH 45/88] Redesign `MyProfile`, `User` and `ChatInfo` pages --- assets/l10n/en-US.ftl | 2 + assets/l10n/ru-RU.ftl | 4 +- lib/domain/service/contact.dart | 4 +- .../page/home/page/chat/info/controller.dart | 90 +++- lib/ui/page/home/page/chat/info/view.dart | 474 +++++++++--------- lib/ui/page/home/page/my_profile/view.dart | 13 +- .../page/home/page/my_profile/widget/bio.dart | 2 + .../home/page/my_profile/widget/name.dart | 1 + lib/ui/page/home/page/user/controller.dart | 114 +++-- lib/ui/page/home/page/user/view.dart | 448 ++++++++--------- .../home/tab/chats/widget/recent_chat.dart | 2 +- lib/ui/page/home/widget/avatar.dart | 43 +- lib/ui/page/home/widget/big_avatar.dart | 20 +- lib/ui/page/home/widget/direct_link.dart | 208 +++++--- lib/ui/page/home/widget/quick_button.dart | 79 --- 15 files changed, 810 insertions(+), 694 deletions(-) delete mode 100644 lib/ui/page/home/widget/quick_button.dart diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index 8fb132b40b9..3f5ea2651ee 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -587,6 +587,7 @@ label_audio_call = Audio call{$by -> *[other] {" "}by {$by} } label_audio_notifications = Audio notifications +label_avatar = Avatar label_avatar_removed = {$author} removed avatar label_avatar_removed1 = {$author} label_avatar_removed2 = {" "}removed avatar @@ -784,6 +785,7 @@ label_image_downloaded = Image downloaded. label_image_saved_to_gallery = Image saved to gallery. label_in_message = In message label_incoming_call = Incoming call +label_info = Info label_introduction_description1 = Access to a guest account is maintained for one year or until: diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index b74f6d64d28..1737c88e1b1 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -197,7 +197,7 @@ btn_download_all = Скачать всё btn_download_all_as = Скачать всё как btn_download_as = Скачать как btn_download_application = Скачать приложение -btn_edit = Редактировать +btn_edit = Изменить btn_email = E-mail btn_feedback = Пожелания и предложения btn_file = Файл @@ -611,6 +611,7 @@ label_audio_call = Аудиозвонок{$by -> *[other] {" "}от {$by} } label_audio_notifications = Звуковые уведомления +label_avatar = Аватар label_avatar_removed = {$author} удалил аватар label_avatar_removed1 = {$author} label_avatar_removed2 = {" "}удалил аватар @@ -809,6 +810,7 @@ label_image_downloaded = Изображение загружено. label_image_saved_to_gallery = Изображение сохранено в галерею. label_in_message = В сообщении label_incoming_call = Входящий звонок +label_info = Информация label_introduction_description1 = Доступ к гостевому аккаунту сохраняется в течение одного года или пока: diff --git a/lib/domain/service/contact.dart b/lib/domain/service/contact.dart index 3ba74ac231f..605fd000af3 100644 --- a/lib/domain/service/contact.dart +++ b/lib/domain/service/contact.dart @@ -58,11 +58,11 @@ class ContactService extends DisposableService { } /// Adds the specified [user] to the current [MyUser]'s address book. - Future createChatContact(User user) { + Future createChatContact(User user, {UserName? name}) { Log.debug('createChatContact($user)', '$runtimeType'); return _contactRepository.createChatContact( - user.name ?? UserName(user.num.toString()), + name ?? user.name ?? UserName(user.num.toString()), user.id, ); } diff --git a/lib/ui/page/home/page/chat/info/controller.dart b/lib/ui/page/home/page/chat/info/controller.dart index b7f60393dd3..e5e0734794d 100644 --- a/lib/ui/page/home/page/chat/info/controller.dart +++ b/lib/ui/page/home/page/chat/info/controller.dart @@ -22,6 +22,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -92,6 +93,16 @@ class ChatInfoController extends GetxController { /// enabled. final RxBool profileEditing = RxBool(false); + /// Index of the [Block] that should be highlighted. + final RxnInt highlighted = RxnInt(); + + /// [ItemScrollController] of the profile's [ScrollablePositionedList]. + final ItemScrollController itemScrollController = ItemScrollController(); + + /// [ItemPositionsListener] of the profile's [ScrollablePositionedList]. + final ItemPositionsListener positionsListener = + ItemPositionsListener.create(); + /// [Chat.name] field state. late final TextFieldState name; @@ -122,6 +133,13 @@ class ChatInfoController extends GetxController { /// Settings repository, used to retrieve the [background]. final AbstractSettingsRepository _settingsRepo; + /// [Timer] resetting the [highlight] value after the [_highlightTimeout] has + /// passed. + Timer? _highlightTimer; + + /// [Duration] of the [highlight]ing. + static const Duration _highlightTimeout = Duration(seconds: 1); + /// Worker to react on [chat] changes. Worker? _worker; @@ -170,31 +188,18 @@ class ChatInfoController extends GetxController { if (s.text.isNotEmpty) { ChatName(s.text); } + + s.error.value = null; } on FormatException { s.error.value = 'err_incorrect_input'.l10n; } - if (s.error.value == null) { - final ChatName? name = ChatName.tryParse(s.text); - if (chat?.chat.value.name == name) { - return; - } + s.focus.unfocus(); - s.status.value = RxStatus.loading(); - s.editable.value = false; - - try { - await _chatService.renameChat(chat!.chat.value.id, name); - s.unsubmit(); - } on RenameChatException catch (e) { - s.error.value = e.toString(); - } catch (e) { - MessagePopup.error(e.toString()); - rethrow; - } finally { - s.status.value = RxStatus.empty(); - s.editable.value = true; - } + if ((s.text.isEmpty && chat?.chat.value.name?.val == null) || + s.text == chat?.chat.value.name?.val) { + s.unsubmit(); + return; } }, ); @@ -213,6 +218,7 @@ class ChatInfoController extends GetxController { @override void onClose() { _worker?.dispose(); + _highlightTimer?.cancel(); _chatSubscription?.cancel(); _membersSubscription?.cancel(); membersScrollController.dispose(); @@ -289,6 +295,40 @@ class ChatInfoController extends GetxController { } } + /// Renames the [Chat] to the [name]. + Future submitName() async { + ChatName? name; + + try { + name = this.name.text.isEmpty ? null : ChatName(this.name.text); + } on FormatException catch (_) { + this.name.status.value = RxStatus.empty(); + this.name.error.value = 'err_incorrect_input'.l10n; + this.name.unsubmit(); + return; + } + + if (this.name.error.value == null) { + this.name.status.value = RxStatus.loading(); + this.name.editable.value = false; + + try { + await _chatService.renameChat(chat!.chat.value.id, name); + this.name.status.value = RxStatus.empty(); + this.name.unsubmit(); + } on RenameChatException catch (e) { + this.name.status.value = RxStatus.empty(); + this.name.error.value = e.toString(); + } catch (e) { + this.name.status.value = RxStatus.empty(); + MessagePopup.error(e.toString()); + rethrow; + } finally { + this.name.editable.value = true; + } + } + } + /// Marks the [chat] as favorited. Future favoriteChat() async { try { @@ -411,6 +451,16 @@ class ChatInfoController extends GetxController { await _chatService.deleteChatDirectLink(chatId); } + /// Highlights the [Block] at the [i] index. + void highlight(int i) { + highlighted.value = i; + + _highlightTimer?.cancel(); + _highlightTimer = Timer(_highlightTimeout, () { + highlighted.value = null; + }); + } + /// Fetches the [chat]. Future _fetchChat() async { status.value = RxStatus.loading(); diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index cca70e6905c..da9efbcf1c9 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -19,6 +19,7 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '/config.dart'; import '/domain/model/chat.dart'; @@ -36,10 +37,9 @@ import '/ui/page/home/widget/avatar.dart'; import '/ui/page/home/widget/big_avatar.dart'; import '/ui/page/home/widget/block.dart'; import '/ui/page/home/widget/direct_link.dart'; -import '/ui/page/home/widget/quick_button.dart'; +import '/ui/page/home/widget/highlighted_container.dart'; import '/ui/page/login/widget/primary_button.dart'; import '/ui/widget/animated_button.dart'; -import '/ui/widget/animated_switcher.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/context_menu/region.dart'; import '/ui/widget/member_tile.dart'; @@ -48,7 +48,6 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/ui/widget/widget_button.dart'; import '/util/message_popup.dart'; -import '/util/platform_utils.dart'; import 'controller.dart'; /// View of the [Routes.chatInfo] page. @@ -86,27 +85,46 @@ class ChatInfoView extends StatelessWidget { ); } + Widget highlighted({ + required int index, + required Widget child, + }) { + return HighlightedContainer( + highlight: c.highlighted.value == index, + child: child, + ); + } + + final List blocks = [ + const SizedBox(height: 8), + highlighted(index: 0, child: _profile(c, context)), + highlighted(index: 1, child: _status(c, context)), + if (!c.isMonolog) ...[ + highlighted( + index: 2, + child: SelectionContainer.disabled(child: _link(c, context)), + ), + SelectionContainer.disabled(child: _members(c, context)), + ], + SelectionContainer.disabled( + child: Block(children: [_actions(c, context)]), + ), + const SizedBox(height: 8), + ]; + return Scaffold( appBar: CustomAppBar(title: _bar(c, context)), body: Scrollbar( controller: c.scrollController, child: SelectionArea( - child: ListView( - controller: c.scrollController, + child: ScrollablePositionedList.builder( key: const Key('ChatInfoScrollable'), - children: [ - const SizedBox(height: 8), - _profile(c, context), - _quick(c, context), - if (!c.isMonolog) ...[ - SelectionContainer.disabled(child: _link(c, context)), - SelectionContainer.disabled(child: _members(c, context)), - ], - SelectionContainer.disabled( - child: Block(children: [_actions(c, context)]), - ), - const SizedBox(height: 8), - ], + itemCount: blocks.length, + itemBuilder: (_, i) => blocks[i], + scrollController: c.scrollController, + itemScrollController: c.itemScrollController, + itemPositionsListener: c.positionsListener, + initialScrollIndex: 0, ), ), ), @@ -118,16 +136,8 @@ class ChatInfoView extends StatelessWidget { /// Builds the [Block] displaying a [ChatAvatar], if any, and [ChatName]. Widget _profile(ChatInfoController c, BuildContext context) { - final style = Theme.of(context).style; - return Block( - overlay: [ - EditBlockButton( - key: const Key('EditProfileButton'), - onPressed: c.profileEditing.toggle, - editing: c.profileEditing.value, - ), - ], + padding: const EdgeInsets.fromLTRB(32, 16, 32, 8), children: [ SelectionContainer.disabled( child: BigAvatarWidget.chat( @@ -135,78 +145,10 @@ class ChatInfoView extends StatelessWidget { key: Key('ChatAvatar_${c.chat!.id}'), loading: c.avatar.value.isLoading, error: c.avatar.value.errorMessage, + onUpload: c.pickAvatar, + onDelete: c.chat?.avatar.value != null ? c.deleteAvatar : null, ), ), - Obx(() { - final List children; - - if (c.profileEditing.value) { - children = [ - const SizedBox(height: 4), - SelectionContainer.disabled( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - WidgetButton( - key: const Key('UploadAvatar'), - onPressed: c.pickAvatar, - child: Text( - 'btn_upload'.l10n, - style: style.fonts.small.regular.primary, - ), - ), - if (c.chat?.avatar.value != null) ...[ - Text( - 'space_or_space'.l10n, - style: style.fonts.small.regular.onBackground, - ), - WidgetButton( - key: const Key('DeleteAvatar'), - onPressed: c.deleteAvatar, - child: Text( - 'btn_delete'.l10n.toLowerCase(), - style: style.fonts.small.regular.primary, - ), - ), - ], - ], - ), - ), - const SizedBox(height: 18), - SelectionContainer.disabled( - child: ReactiveTextField( - key: const Key('RenameChatField'), - state: c.name, - label: 'label_name'.l10n, - hint: c.chat?.title, - formatters: [LengthLimitingTextInputFormatter(100)], - ), - ), - const SizedBox(height: 4), - ]; - } else { - children = [ - const SizedBox(height: 18), - Container(width: double.infinity), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), - child: Text( - c.chat?.title ?? c.name.text, - style: style.fonts.large.regular.onBackground, - ), - ), - ]; - } - - return AnimatedSizeAndFade( - fadeDuration: 250.milliseconds, - sizeDuration: 250.milliseconds, - child: Column( - key: Key(c.profileEditing.value.toString()), - children: children, - ), - ); - }), ], ); } @@ -218,13 +160,6 @@ class ChatInfoView extends StatelessWidget { return Block( title: 'label_direct_chat_link'.l10n, padding: Block.defaultPadding.copyWith(bottom: 10), - overlay: [ - EditBlockButton( - key: const Key('EditLinkButton'), - onPressed: c.linkEditing.toggle, - editing: c.linkEditing.value, - ), - ], children: [ Obx(() { return Column( @@ -250,7 +185,18 @@ class ChatInfoView extends StatelessWidget { }, background: c.background.value, editing: c.linkEditing.value, - onEditing: (b) => c.linkEditing.value = b, + onEditing: (b) { + if (b) { + c.itemScrollController.scrollTo( + index: 3, + curve: Curves.ease, + duration: const Duration(milliseconds: 600), + ); + c.highlight(2); + } + + c.linkEditing.value = b; + }, ), ], ); @@ -261,25 +207,12 @@ class ChatInfoView extends StatelessWidget { /// Returns the [Block] displaying the [Chat.members]. Widget _members(ChatInfoController c, BuildContext context) { + final style = Theme.of(context).style; + return Block( padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), title: 'label_participants' .l10nfmt({'count': c.chat!.chat.value.membersCount}), - overlay: [ - Positioned( - top: 0, - right: 0, - child: AnimatedButton( - key: const Key('AddMemberButton'), - decorator: (child) => Padding( - padding: const EdgeInsets.fromLTRB(2, 4, 2, 2), - child: child, - ), - onPressed: () => AddChatMemberView.show(context, chatId: id), - child: const SvgIcon(SvgIcons.addMemberSmall), - ), - ), - ], children: [ Obx(() { final List members = []; @@ -371,16 +304,136 @@ class ChatInfoView extends StatelessWidget { ], ); }), + const SizedBox(height: 16), + WidgetButton( + onPressed: () => AddChatMemberView.show(context, chatId: id), + child: Text( + 'btn_add_participant'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ], + ); + } + + Widget _status(ChatInfoController c, BuildContext context) { + final style = Theme.of(context).style; + + return Block( + padding: const EdgeInsets.fromLTRB(32, 16, 32, 8), + children: [ + Obx(() { + final List children; + + if (c.profileEditing.value) { + children = [ + const SizedBox(height: 18), + SelectionContainer.disabled( + child: ReactiveTextField( + key: const Key('RenameChatField'), + state: c.name, + label: 'label_name'.l10n, + hint: c.chat?.title, + floatingLabelBehavior: FloatingLabelBehavior.always, + formatters: [LengthLimitingTextInputFormatter(100)], + ), + ), + const SizedBox(height: 16), + SelectionContainer.disabled( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 16), + WidgetButton( + onPressed: () { + c.submitName(); + c.profileEditing.value = false; + }, + child: Text( + 'btn_save'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + const Spacer(), + WidgetButton( + onPressed: () { + c.name.text = c.chat!.chat.value.name?.val ?? ''; + c.profileEditing.value = false; + }, + child: SelectionContainer.disabled( + child: Text( + 'btn_cancel'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + const SizedBox(width: 16), + ], + ), + ), + const SizedBox(height: 4), + ]; + } else { + children = [ + Container(width: double.infinity), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Text( + c.chat?.title ?? c.name.text, + style: style.fonts.larger.regular.onBackground, + ), + ), + const SizedBox(height: 12), + WidgetButton( + onPressed: () { + c.itemScrollController.scrollTo( + index: 2, + curve: Curves.ease, + duration: const Duration(milliseconds: 600), + ); + c.highlight(1); + c.profileEditing.value = true; + }, + child: SelectionContainer.disabled( + child: Text( + 'btn_edit'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + ]; + } + + return AnimatedSizeAndFade( + fadeDuration: 250.milliseconds, + sizeDuration: 250.milliseconds, + child: Column( + key: Key(c.profileEditing.value.toString()), + children: children, + ), + ); + }), ], ); } /// Returns the action buttons to do with this [Chat]. Widget _actions(ChatInfoController c, BuildContext context) { + final bool favorite = c.chat?.chat.value.favoritePosition != null; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), + ActionButton( + onPressed: favorite ? c.unfavoriteChat : c.favoriteChat, + text: favorite + ? 'btn_delete_from_favorites'.l10n + : 'btn_add_to_favorites'.l10n, + trailing: SvgIcon( + favorite ? SvgIcons.favorite16 : SvgIcons.unfavorite16, + ), + ), if (!c.isMonolog) ActionButton( onPressed: () => _reportChat(c, context), @@ -509,132 +562,87 @@ class ChatInfoView extends StatelessWidget { final Widget title; - if (!c.displayName.value) { - title = Row( - key: const Key('Profile'), - children: [ - const StyledBackButton(), - const SizedBox(width: 8), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 0, 32, 0), - child: Center(child: Text('label_profile'.l10n)), - ), - ), - ], - ); - } else { - title = Row( - children: [ - const StyledBackButton(), - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: AvatarWidget.fromRxChat( - c.chat, - radius: AvatarRadius.medium, - ), + title = Row( + children: [ + const StyledBackButton(), + Material( + elevation: 6, + type: MaterialType.circle, + shadowColor: style.colors.onBackgroundOpacity27, + color: style.colors.onPrimary, + child: AvatarWidget.fromRxChat( + c.chat, + radius: AvatarRadius.medium, ), - const SizedBox(width: 10), - Flexible( - child: DefaultTextStyle.merge( - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Obx(() { - return Row( - children: [ - Flexible( - child: Text( - c.chat!.title, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: style.fonts.big.regular.onBackground, - ), + ), + const SizedBox(width: 10), + Flexible( + child: DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + c.chat!.title, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: style.fonts.big.regular.onBackground, ), - Obx(() { - if (c.chat?.chat.value.muted == null) { - return const SizedBox(); - } - - return const Padding( - padding: EdgeInsets.only(left: 5), - child: SvgIcon(SvgIcons.muted), - ); - }), - ], - ); - }), - ChatSubtitle(c.chat!, c.me), - ], - ), + ), + Obx(() { + if (c.chat?.chat.value.muted == null) { + return const SizedBox(); + } + + return const Padding( + padding: EdgeInsets.only(left: 5), + child: SvgIcon(SvgIcons.muted), + ); + }), + ], + ); + }), + ChatSubtitle(c.chat!, c.me), + ], ), ), - const SizedBox(width: 10), - ], - ); - } + ), + const SizedBox(width: 10), + ], + ); return Row( children: [ - Expanded( - child: SafeAnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: title, - ), + Expanded(child: title), + const SizedBox(width: 8), + AnimatedButton( + onPressed: () => router.chat(id), + child: const SvgIcon(SvgIcons.chat), + ), + const SizedBox(width: 28), + AnimatedButton( + onPressed: () => c.call(true), + child: const SvgIcon(SvgIcons.chatVideoCall), + ), + const SizedBox(width: 28), + AnimatedButton( + key: const Key('AudioCall'), + onPressed: () => c.call(false), + child: const SvgIcon(SvgIcons.chatAudioCall), ), editButton, ], ); } - /// Returns the [QuickButton] for quick actions to do with this [Chat]. - Widget _quick(ChatInfoController c, BuildContext context) { - return SelectionContainer.disabled( - child: Center( - child: Container( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - constraints: - context.isNarrow ? null : const BoxConstraints(maxWidth: 400), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: QuickButton( - label: 'label_chat'.l10n, - icon: SvgIcons.chat, - onPressed: () => router.chat(id), - ), - ), - const SizedBox(width: 6), - Expanded( - child: QuickButton( - label: 'btn_audio'.l10n, - icon: SvgIcons.chatAudioCall, - onPressed: () => c.call(false), - ), - ), - const SizedBox(width: 6), - Expanded( - child: QuickButton( - label: 'btn_video'.l10n, - icon: SvgIcons.chatVideoCall, - onPressed: () => c.call(true), - ), - ), - ], - ), - ), - ), - ); - } - /// Opens a confirmation popup leaving this [Chat]. Future _leaveGroup(ChatInfoController c, BuildContext context) async { final bool? result = await MessagePopup.alert( diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index abe061d4d27..be943a5245f 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -125,6 +125,7 @@ class MyProfileView extends StatelessWidget { child: Column( children: [ block( + title: 'label_avatar'.l10n, children: [ Obx(() { return BigAvatarWidget.myUser( @@ -136,7 +137,11 @@ class MyProfileView extends StatelessWidget { : null, ); }), - const SizedBox(height: 12), + ], + ), + block( + title: 'label_about'.l10n, + children: [ Paddings.basic( Obx(() { return UserNameField( @@ -145,11 +150,7 @@ class MyProfileView extends StatelessWidget { ); }), ), - ], - ), - block( - title: 'label_about'.l10n, - children: [ + const SizedBox(height: 6), Paddings.basic( Obx(() { return UserBioField( diff --git a/lib/ui/page/home/page/my_profile/widget/bio.dart b/lib/ui/page/home/page/my_profile/widget/bio.dart index 0cee77758c1..eae78f2d9b5 100644 --- a/lib/ui/page/home/page/my_profile/widget/bio.dart +++ b/lib/ui/page/home/page/my_profile/widget/bio.dart @@ -91,7 +91,9 @@ class _UserBioFieldState extends State { return ReactiveTextField( key: const Key('BioField'), state: _state, + floatingLabelBehavior: FloatingLabelBehavior.always, label: 'label_description'.l10n, + hint: 'label_about'.l10n, filled: true, maxLines: null, formatters: [LengthLimitingTextInputFormatter(4096)], diff --git a/lib/ui/page/home/page/my_profile/widget/name.dart b/lib/ui/page/home/page/my_profile/widget/name.dart index ac5a9785f9e..cf0e46b2a7c 100644 --- a/lib/ui/page/home/page/my_profile/widget/name.dart +++ b/lib/ui/page/home/page/my_profile/widget/name.dart @@ -93,6 +93,7 @@ class _UserNameFieldState extends State { label: 'label_name'.l10n, hint: 'label_name_hint'.l10n, filled: true, + floatingLabelBehavior: FloatingLabelBehavior.always, onSuffixPressed: _state.text.isEmpty ? null : () { diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index f906c7ffc19..d17df5c67f8 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -21,6 +21,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -95,6 +96,13 @@ class UserController extends GetxController { /// [GlobalKey] of the more [ContextMenuRegion] button. final GlobalKey moreKey = GlobalKey(); + /// [ItemScrollController] of the profile's [ScrollablePositionedList]. + final ItemScrollController itemScrollController = ItemScrollController(); + + /// [ItemPositionsListener] of the profile's [ScrollablePositionedList]. + final ItemPositionsListener positionsListener = + ItemPositionsListener.create(); + /// [TextFieldState] for blocking reason. final TextFieldState reason = TextFieldState(); @@ -117,6 +125,9 @@ class UserController extends GetxController { /// Indicator whether [AppBar] should display the [UserName] and [UserAvatar]. final RxBool displayName = RxBool(false); + /// Index of the [Block] that should be highlighted. + final RxnInt highlighted = RxnInt(); + /// [UserService] fetching the [user]. final UserService _userService; @@ -136,6 +147,13 @@ class UserController extends GetxController { /// updating the [name]. Worker? _worker; + /// [Timer] resetting the [highlight] value after the [_highlightTimeout] has + /// passed. + Timer? _highlightTimer; + + /// [Duration] of the [highlight]ing. + static const Duration _highlightTimeout = Duration(seconds: 1); + /// Subscription for the [user] changes. StreamSubscription? _userSubscription; @@ -166,37 +184,12 @@ class UserController extends GetxController { @override void onInit() { name = TextFieldState( - onChanged: (s) async { - s.error.value = null; - s.focus.unfocus(); - - if (s.text == contact.value!.contact.value.name.val) { - s.unsubmit(); - return; - } - - final UserName? name = UserName.tryParse(s.text); - if (name == null) { - s.status.value = RxStatus.empty(); - s.error.value = 'err_incorrect_input'.l10n; - s.unsubmit(); - } else { - s.status.value = RxStatus.loading(); - s.editable.value = false; - + onChanged: (s) { + if (s.text.isNotEmpty) { try { - await _contactService.changeContactName(contact.value!.id, name); - s.status.value = RxStatus.empty(); - s.unsubmit(); - } on UpdateChatContactNameException catch (e) { - s.status.value = RxStatus.empty(); - s.error.value = e.toString(); + UserName(s.text); } catch (e) { - s.status.value = RxStatus.empty(); - MessagePopup.error(e.toString()); - rethrow; - } finally { - s.editable.value = true; + s.error.value = e.toString(); } } }, @@ -231,15 +224,16 @@ class UserController extends GetxController { _contactWorker?.dispose(); _worker?.dispose(); scrollController.dispose(); + _highlightTimer?.cancel(); super.onClose(); } /// Adds the [user] to the contacts list of the authenticated [MyUser]. - Future addToContacts() async { + Future addToContacts({UserName? name}) async { if (contactId == null) { status.value = RxStatus.loadingMore(); try { - await _contactService.createChatContact(user!.user.value); + await _contactService.createChatContact(user!.user.value, name: name); } catch (e) { MessagePopup.error(e); rethrow; @@ -308,6 +302,54 @@ class UserController extends GetxController { } } + /// Renames the [ChatContact] this [User] is linked to. + /// + /// If no [ChatContact] is linked, then this method creates one. + Future submitName() async { + name.error.value = null; + name.focus.unfocus(); + + if (name.text == contact.value?.contact.value.name.val) { + name.unsubmit(); + return; + } + + UserName? userName; + try { + userName = UserName(name.text); + } on FormatException catch (_) { + name.status.value = RxStatus.empty(); + name.error.value = 'err_incorrect_input'.l10n; + name.unsubmit(); + return; + } + + if (name.error.value == null) { + if (contactId == null) { + await addToContacts(name: userName); + return; + } + + name.status.value = RxStatus.loading(); + name.editable.value = false; + + try { + await _contactService.changeContactName(contact.value!.id, userName); + name.status.value = RxStatus.empty(); + name.unsubmit(); + } on UpdateChatContactNameException catch (e) { + name.status.value = RxStatus.empty(); + name.error.value = e.toString(); + } catch (e) { + name.status.value = RxStatus.empty(); + MessagePopup.error(e.toString()); + rethrow; + } finally { + name.editable.value = true; + } + } + } + // TODO: Replace with GraphQL mutation when implemented. /// Reports the [user]. Future report() async { @@ -475,6 +517,16 @@ class UserController extends GetxController { } } + /// Highlights the [Block] at the [i] index. + void highlight(int i) { + highlighted.value = i; + + _highlightTimer?.cancel(); + _highlightTimer = Timer(_highlightTimeout, () { + highlighted.value = null; + }); + } + /// Fetches the [user] value from the [_userService]. Future _fetchUser() async { try { diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 9051195eac7..62fe43fc75b 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -19,6 +19,7 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '/domain/model/chat.dart'; import '/domain/model/user.dart'; @@ -33,11 +34,11 @@ import '/ui/page/home/widget/avatar.dart'; import '/ui/page/home/widget/big_avatar.dart'; import '/ui/page/home/widget/block.dart'; import '/ui/page/home/widget/copy_or_share.dart'; +import '/ui/page/home/widget/highlighted_container.dart'; import '/ui/page/home/widget/info_tile.dart'; import '/ui/page/home/widget/paddings.dart'; -import '/ui/page/home/widget/quick_button.dart'; import '/ui/page/login/widget/primary_button.dart'; -import '/ui/widget/animated_switcher.dart'; +import '/ui/widget/animated_button.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/context_menu/region.dart'; import '/ui/widget/progress_indicator.dart'; @@ -45,7 +46,6 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/ui/widget/widget_button.dart'; import '/util/message_popup.dart'; -import '/util/platform_utils.dart'; import 'controller.dart'; import 'widget/blocklist_record.dart'; @@ -78,39 +78,48 @@ class UserView extends StatelessWidget { ); } + Widget highlighted({ + required int index, + required Widget child, + }) { + return HighlightedContainer( + highlight: c.highlighted.value == index, + child: child, + ); + } + + final List blocks = [ + const SizedBox(height: 8), + if (c.isBlocked != null) + Block( + title: 'label_user_is_blocked'.l10n, + children: [ + BlocklistRecordWidget(c.isBlocked!, onUnblock: c.unblock), + ], + ), + highlighted(index: 0, child: _profile(c, context)), + _bio(c, context), + _info(c), + SelectionContainer.disabled( + child: Block(children: [_actions(c, context)]), + ), + const SizedBox(height: 8), + ]; + return Scaffold( appBar: CustomAppBar(title: _bar(c, context)), body: Scrollbar( controller: c.scrollController, - child: Obx(() { - return SelectionArea( - child: ListView( - key: const Key('UserScrollable'), - controller: c.scrollController, - children: [ - const SizedBox(height: 8), - if (c.isBlocked != null) - Block( - title: 'label_user_is_blocked'.l10n, - children: [ - BlocklistRecordWidget( - c.isBlocked!, - onUnblock: c.unblock, - ), - ], - ), - _profile(c, context), - _quick(c, context), - _bio(c, context), - Block(children: [_num(c)]), - SelectionContainer.disabled( - child: Block(children: [_actions(c, context)]), - ), - const SizedBox(height: 8), - ], - ), - ); - }), + child: SelectionArea( + child: ScrollablePositionedList.builder( + key: const Key('UserScrollable'), + itemCount: blocks.length, + itemBuilder: (_, i) => blocks[i], + scrollController: c.scrollController, + itemScrollController: c.itemScrollController, + itemPositionsListener: c.positionsListener, + ), + ), ), ); }); @@ -120,18 +129,9 @@ class UserView extends StatelessWidget { /// Builds a [Block] displaying a [User.avatar] and [User.name]. Widget _profile(UserController c, BuildContext context) { - final style = Theme.of(context).style; - return Obx(() { return Block( - overlay: [ - if (c.contact.value != null) - EditBlockButton( - key: const Key('EditProfileButton'), - onPressed: c.profileEditing.toggle, - editing: c.profileEditing.value, - ), - ], + padding: const EdgeInsets.fromLTRB(32, 16, 32, 16), children: [ SelectionContainer.disabled( child: BigAvatarWidget.user( @@ -141,107 +141,128 @@ class UserView extends StatelessWidget { error: c.avatar.value.errorMessage, ), ), - Obx(() { - final List children; - - if (c.profileEditing.value) { - children = [ - const SizedBox(height: 4), - SelectionContainer.disabled( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - WidgetButton( - key: const Key('UploadAvatar'), - onPressed: c.pickAvatar, - child: Text( - 'btn_upload'.l10n, - style: style.fonts.small.regular.primary, - ), - ), - ], - ), + ], + ); + }); + } + + /// Returns the [User.status] visual representation. + Widget _bio(UserController c, BuildContext context) { + final style = Theme.of(context).style; + + return Block( + padding: Block.defaultPadding.copyWith(top: 8, bottom: 8), + children: [ + Obx(() { + final List children; + + if (c.profileEditing.value) { + children = [ + const SizedBox(height: 18), + SelectionContainer.disabled( + child: ReactiveTextField( + state: c.name, + label: 'label_name'.l10n, + hint: c.user!.title, + formatters: [LengthLimitingTextInputFormatter(100)], ), - const SizedBox(height: 18), - SelectionContainer.disabled( - child: ReactiveTextField( - state: c.name, - label: 'label_name'.l10n, - hint: c.user!.title, - formatters: [LengthLimitingTextInputFormatter(100)], + ), + const SizedBox(height: 16), + Row( + children: [ + const SizedBox(width: 16), + WidgetButton( + onPressed: () { + c.submitName(); + c.profileEditing.value = false; + }, + child: SelectionContainer.disabled( + child: Text( + 'btn_save'.l10n, + style: style.fonts.small.regular.primary, + ), + ), ), - ), - const SizedBox(height: 4), - ]; - } else { - children = [ - const SizedBox(height: 18), - Container(width: double.infinity), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), - child: Text( - c.contact.value?.contact.value.name.val ?? c.name.text, - style: style.fonts.large.regular.onBackground, + const Spacer(), + WidgetButton( + onPressed: () => c.profileEditing.value = false, + child: SelectionContainer.disabled( + child: Text( + 'btn_cancel'.l10n, + style: style.fonts.small.regular.primary, + ), + ), ), + const SizedBox(width: 16), + ], + ), + ]; + } else { + children = [ + const SizedBox(height: 8), + Container(width: double.infinity), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + child: Text( + c.contact.value?.contact.value.name.val ?? c.name.text, + style: style.fonts.larger.regular.onBackground, ), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), + ), + const SizedBox(height: 8), + WidgetButton( + onPressed: () { + c.itemScrollController.scrollTo( + index: c.isBlocked != null ? 3 : 2, + curve: Curves.ease, + duration: const Duration(milliseconds: 600), + ); + c.highlight(1); + c.profileEditing.value = true; + }, + child: SelectionContainer.disabled( child: Text( - c.user?.user.value.getStatus() ?? '', - style: style.fonts.small.regular.secondary, + 'btn_edit'.l10n, + style: style.fonts.small.regular.primary, ), ), - ]; - } - - return AnimatedSizeAndFade( - fadeDuration: 250.milliseconds, - sizeDuration: 250.milliseconds, - child: Column( - key: Key(c.profileEditing.value.toString()), - children: children, ), - ); - }), - ], - ); - }); - } + ]; + } - /// Returns the [User.status] visual representation. - Widget _bio(UserController c, BuildContext context) { - final style = Theme.of(context).style; + return AnimatedSizeAndFade( + fadeDuration: 250.milliseconds, + sizeDuration: 250.milliseconds, + child: Column( + key: Key(c.profileEditing.value.toString()), + children: children, + ), + ); + }), + ], + ); + } + /// Returns the [User.num] and [User.bio] visual representation. + Widget _info(UserController c) { final UserBio? bio = c.user?.user.value.bio; - if (bio != null) { - return Block( - padding: Block.defaultPadding.copyWith(top: 8, bottom: 8), - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - bio.toString(), - style: style.fonts.normal.regular.secondary, + return Block( + title: 'label_info'.l10n, + children: [ + Paddings.basic( + InfoTile( + title: 'Gapopa ID', + content: c.user!.user.value.num.toString(), + trailing: CopyOrShareButton( + c.user!.user.value.num.toString(), ), ), - ], - ); - } else { - return const SizedBox(); - } - } - - /// Returns the [User.num] visual representation. - Widget _num(UserController c) { - return Paddings.basic( - InfoTile( - key: const Key('NumCopyable'), - title: 'label_num'.l10n, - content: c.user!.user.value.num.toString(), - trailing: CopyOrShareButton(c.user!.user.value.num.toString()), - ), + ), + if (bio != null) + Paddings.basic( + InfoTile(title: 'label_about'.l10n, content: bio.toString()), + ), + ], ); } @@ -356,8 +377,8 @@ class UserView extends StatelessWidget { ), ], ContextMenuButton( - onPressed: () => _reportUser(c, context), label: 'btn_report'.l10n, + onPressed: () => _reportUser(c, context), trailing: const SvgIcon(SvgIcons.report), inverted: const SvgIcon(SvgIcons.reportWhite), ), @@ -380,79 +401,74 @@ class UserView extends StatelessWidget { final Widget title; - if (!c.displayName.value) { - title = Row( - key: const Key('Profile'), - children: [ - const StyledBackButton(), - const SizedBox(width: 8), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(32, 0, 32, 0), - child: Center(child: Text('label_profile'.l10n)), - ), - ), - ], - ); - } else { - title = Row( - children: [ - const StyledBackButton(), - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: Center( - child: AvatarWidget.fromRxUser( - c.user, - radius: AvatarRadius.medium, - ), + title = Row( + children: [ + const StyledBackButton(), + Material( + elevation: 6, + type: MaterialType.circle, + shadowColor: style.colors.onBackgroundOpacity27, + color: style.colors.onPrimary, + child: Center( + child: AvatarWidget.fromRxUser( + c.user, + radius: AvatarRadius.medium, ), ), - const SizedBox(width: 10), - Expanded( - child: DefaultTextStyle.merge( - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: Obx(() { - final String? subtitle = c.user?.user.value.getStatus(); - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + ), + const SizedBox(width: 10), + Expanded( + child: DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: Obx(() { + final String? subtitle = c.user?.user.value.getStatus(); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.user!.title, + style: style.fonts.big.regular.onBackground, + ), + if (subtitle?.isNotEmpty == true) Text( - c.user!.title, - style: style.fonts.big.regular.onBackground, - ), - if (subtitle?.isNotEmpty == true) - Text( - key: Key( - c.user?.user.value.presence?.name.capitalizeFirst ?? - '', - ), - subtitle!, - style: style.fonts.small.regular.secondary, - ) - ], - ); - }), - ), + key: Key( + c.user?.user.value.presence?.name.capitalizeFirst ?? '', + ), + subtitle!, + style: style.fonts.small.regular.secondary, + ) + ], + ); + }), ), - const SizedBox(width: 10), - ], - ); - } + ), + const SizedBox(width: 10), + ], + ); return Row( children: [ - Expanded( - child: SafeAnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: title, - ), + Expanded(child: title), + const SizedBox(width: 8), + AnimatedButton( + onPressed: () => router.chat(c.user!.user.value.dialog), + child: const SvgIcon(SvgIcons.chat), + ), + const SizedBox(width: 28), + AnimatedButton( + onPressed: () => c.call(true), + child: const SvgIcon(SvgIcons.chatVideoCall), ), + const SizedBox(width: 28), + AnimatedButton( + key: const Key('AudioCall'), + onPressed: () => c.call(false), + child: const SvgIcon(SvgIcons.chatAudioCall), + ), + const SizedBox(width: 8), editButton, ], ); @@ -533,48 +549,6 @@ class UserView extends StatelessWidget { }); } - /// Returns the [QuickButton] for quick actions to do with this [User]. - Widget _quick(UserController c, BuildContext context) { - return SelectionContainer.disabled( - child: Center( - child: Container( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - constraints: - context.isNarrow ? null : const BoxConstraints(maxWidth: 400), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: QuickButton( - label: 'label_chat'.l10n, - icon: SvgIcons.chat, - onPressed: c.openChat, - ), - ), - const SizedBox(width: 6), - Expanded( - child: QuickButton( - label: 'btn_audio'.l10n, - icon: SvgIcons.chatAudioCall, - onPressed: () => c.call(false), - ), - ), - const SizedBox(width: 6), - Expanded( - child: QuickButton( - label: 'btn_video'.l10n, - icon: SvgIcons.chatVideoCall, - onPressed: () => c.call(true), - ), - ), - ], - ), - ), - ), - ); - } - /// Opens a confirmation popup hiding the [Chat]-dialog with the [User]. Future _hideChat(UserController c, BuildContext context) async { final style = Theme.of(context).style; diff --git a/lib/ui/page/home/tab/chats/widget/recent_chat.dart b/lib/ui/page/home/tab/chats/widget/recent_chat.dart index 5fcc228313e..df367e371b5 100644 --- a/lib/ui/page/home/tab/chats/widget/recent_chat.dart +++ b/lib/ui/page/home/tab/chats/widget/recent_chat.dart @@ -165,7 +165,7 @@ class RecentChatTile extends StatelessWidget { final style = Theme.of(context).style; final Chat chat = rxChat.chat.value; - final bool isRoute = chat.isRoute(router.route, me); + final bool isRoute = router.routes.any((e) => chat.isRoute(e, me)); final bool inverted = selected || (invertible && isRoute); return Slidable( diff --git a/lib/ui/page/home/widget/avatar.dart b/lib/ui/page/home/widget/avatar.dart index d7bc9f6fd53..9762d404cce 100644 --- a/lib/ui/page/home/widget/avatar.dart +++ b/lib/ui/page/home/widget/avatar.dart @@ -82,6 +82,7 @@ class AvatarWidget extends StatelessWidget { this.label, this.onForbidden, this.child, + this.shape = BoxShape.circle, }); /// Creates an [AvatarWidget] from the specified [contact]. @@ -91,6 +92,7 @@ class AvatarWidget extends StatelessWidget { Avatar? avatar, AvatarRadius? radius, double opacity = 1, + BoxShape shape = BoxShape.circle, }) => AvatarWidget( key: key, @@ -101,6 +103,7 @@ class AvatarWidget extends StatelessWidget { : contact?.users.first.num.val.sum(), radius: radius, opacity: opacity, + shape: shape, ); /// Creates an [AvatarWidget] from the specified reactive [contact]. @@ -111,6 +114,7 @@ class AvatarWidget extends StatelessWidget { AvatarRadius? radius, double opacity = 1, bool badge = true, + BoxShape shape = BoxShape.circle, }) { if (contact == null) { return AvatarWidget.fromContact( @@ -119,6 +123,7 @@ class AvatarWidget extends StatelessWidget { avatar: avatar, radius: radius, opacity: opacity, + shape: shape, ); } @@ -136,6 +141,7 @@ class AvatarWidget extends StatelessWidget { : contact.user.value?.user.value.num.val.sum(), radius: radius, opacity: opacity, + shape: shape, ); }); } @@ -148,6 +154,7 @@ class AvatarWidget extends StatelessWidget { double opacity = 1, bool badge = true, FutureOr Function()? onForbidden, + BoxShape shape = BoxShape.circle, }) => AvatarWidget( key: key, @@ -159,6 +166,7 @@ class AvatarWidget extends StatelessWidget { radius: radius, opacity: opacity, onForbidden: onForbidden, + shape: shape, ); /// Creates an [AvatarWidget] from the specified [user]. @@ -167,6 +175,7 @@ class AvatarWidget extends StatelessWidget { Key? key, AvatarRadius? radius, double opacity = 1, + BoxShape shape = BoxShape.circle, }) => AvatarWidget( key: key, @@ -175,6 +184,7 @@ class AvatarWidget extends StatelessWidget { color: user?.num.val.sum(), radius: radius, opacity: opacity, + shape: shape, ); /// Creates an [AvatarWidget] from the specified reactive [user]. @@ -184,6 +194,7 @@ class AvatarWidget extends StatelessWidget { AvatarRadius? radius, double opacity = 1, bool badge = true, + BoxShape shape = BoxShape.circle, }) { if (user == null) { return AvatarWidget.fromUser( @@ -191,6 +202,7 @@ class AvatarWidget extends StatelessWidget { key: key, radius: radius, opacity: opacity, + shape: shape, ); } @@ -204,6 +216,7 @@ class AvatarWidget extends StatelessWidget { color: user.user.value.num.val.sum(), radius: radius, opacity: opacity, + shape: shape, ), ); } @@ -215,6 +228,7 @@ class AvatarWidget extends StatelessWidget { Key? key, AvatarRadius? radius, double opacity = 1, + BoxShape shape = BoxShape.circle, }) => AvatarWidget( key: key, @@ -231,6 +245,7 @@ class AvatarWidget extends StatelessWidget { color: chat?.colorDiscriminant(me).sum(), radius: radius, opacity: opacity, + shape: shape, ); /// Creates an [AvatarWidget] from the specified [Chat] and its parameters. @@ -242,6 +257,7 @@ class AvatarWidget extends StatelessWidget { Key? key, AvatarRadius? radius, double opacity = 1, + BoxShape shape = BoxShape.circle, }) => AvatarWidget( key: key, @@ -250,6 +266,7 @@ class AvatarWidget extends StatelessWidget { color: chat?.colorDiscriminant(me).sum(), radius: radius, opacity: opacity, + shape: shape, ); /// Creates an [AvatarWidget] from the specified [RxChat]. @@ -259,12 +276,14 @@ class AvatarWidget extends StatelessWidget { AvatarRadius? radius, double opacity = 1, FutureOr Function()? onForbidden, + BoxShape shape = BoxShape.circle, }) { if (chat == null) { return AvatarWidget( key: key, radius: radius, opacity: opacity, + shape: shape, ); } @@ -275,6 +294,7 @@ class AvatarWidget extends StatelessWidget { chat.me, radius: radius, opacity: opacity, + shape: shape, ); } @@ -291,6 +311,7 @@ class AvatarWidget extends StatelessWidget { radius: radius, opacity: opacity, onForbidden: onForbidden, + shape: shape, ); }); } @@ -335,6 +356,9 @@ class AvatarWidget extends StatelessWidget { /// Intended to be used on the [Routes.style] page only. final Widget? child; + /// [BoxShape] to display this [AvatarWidget] of. + final BoxShape shape; + /// Returns minimum diameter of the avatar. double get _minDiameter { if (radius == null) { @@ -400,7 +424,11 @@ class AvatarWidget extends StatelessWidget { end: Alignment.bottomCenter, colors: [gradient.lighten(), gradient], ), - shape: BoxShape.circle, + borderRadius: switch (shape) { + BoxShape.circle => null, + BoxShape.rectangle => BorderRadius.circular(0.035 * _minDiameter) + }, + shape: shape, ), child: Center( child: label ?? @@ -451,7 +479,7 @@ class AvatarWidget extends StatelessWidget { if (avatar == null) defaultAvatar, if (avatar != null || child != null) Positioned.fill( - child: ClipOval( + child: _clip( child: child ?? RetryImage( image!.url, @@ -472,6 +500,17 @@ class AvatarWidget extends StatelessWidget { ); }); } + + /// Applies the appropriate clipping according to the [shape] specified. + Widget _clip({required Widget child}) { + return switch (shape) { + BoxShape.circle => ClipOval(child: child), + BoxShape.rectangle => ClipRRect( + borderRadius: BorderRadius.circular(0.035 * _minDiameter), + child: child, + ), + }; + } } /// Extension adding an ability to get initials from a [String]. diff --git a/lib/ui/page/home/widget/big_avatar.dart b/lib/ui/page/home/widget/big_avatar.dart index a77fdf488b1..fc71f6fccf7 100644 --- a/lib/ui/page/home/widget/big_avatar.dart +++ b/lib/ui/page/home/widget/big_avatar.dart @@ -135,29 +135,28 @@ class _BigAvatarWidgetState extends State { Row( mainAxisSize: MainAxisSize.min, children: [ + if (widget.onDelete != null) const SizedBox(width: 16), if (widget.onUpload != null) WidgetButton( key: const Key('UploadAvatar'), onPressed: widget.onUpload, child: Text( 'btn_upload'.l10n, - style: style.fonts.smaller.regular.primary, + style: style.fonts.small.regular.primary, ), ), - if (widget.onUpload != null && widget.onDelete != null) - Text( - 'space_or_space'.l10n, - style: style.fonts.smaller.regular.onBackground, - ), - if (widget.onDelete != null) + if (widget.onDelete != null) ...[ + const Spacer(), WidgetButton( key: const Key('DeleteAvatar'), onPressed: widget.onDelete, child: Text( - 'btn_delete'.l10n.toLowerCase(), - style: style.fonts.smaller.regular.primary, + 'btn_delete'.l10n, + style: style.fonts.small.regular.primary, ), ), + const SizedBox(width: 16), + ], ], ), if (widget.error != null) ...[ @@ -198,6 +197,7 @@ class _BigAvatarWidgetState extends State { key: _avatarKey, radius: AvatarRadius.largest, badge: false, + shape: BoxShape.rectangle, ); break; @@ -208,6 +208,7 @@ class _BigAvatarWidgetState extends State { key: _avatarKey, radius: AvatarRadius.largest, badge: false, + shape: BoxShape.rectangle, ); break; @@ -217,6 +218,7 @@ class _BigAvatarWidgetState extends State { widget.chat, key: _avatarKey, radius: AvatarRadius.largest, + shape: BoxShape.rectangle, ); break; } diff --git a/lib/ui/page/home/widget/direct_link.dart b/lib/ui/page/home/widget/direct_link.dart index 574860e5219..0b1c717ec62 100644 --- a/lib/ui/page/home/widget/direct_link.dart +++ b/lib/ui/page/home/widget/direct_link.dart @@ -90,7 +90,6 @@ class _DirectLinkFieldState extends State { void initState() { _state = TextFieldState( text: widget.link?.slug.val, - approvable: true, submitted: widget.link != null, debounce: true, onChanged: (s) { @@ -104,43 +103,7 @@ class _DirectLinkFieldState extends State { } } }, - onSubmitted: (s) async { - final ChatDirectLinkSlug? slug = ChatDirectLinkSlug.tryParse(s.text); - - if (s.text.isNotEmpty) { - if (slug == null) { - s.error.value = 'err_invalid_symbols_in_link'.l10n; - } - - if (slug == null || slug == widget.link?.slug) { - setState(() => _editing = false); - widget.onEditing?.call(_editing); - return; - } - } - - if (s.error.value == null) { - s.editable.value = false; - s.status.value = RxStatus.loading(); - - try { - await widget.onSubmit?.call(slug); - s.status.value = RxStatus.success(); - await Future.delayed(const Duration(seconds: 1)); - s.status.value = RxStatus.empty(); - } on CreateChatDirectLinkException catch (e) { - s.status.value = RxStatus.empty(); - s.error.value = e.toMessage(); - } catch (e) { - s.status.value = RxStatus.empty(); - s.error.value = 'err_data_transfer'.l10n; - s.unsubmit(); - rethrow; - } finally { - s.editable.value = true; - } - } - }, + onSubmitted: (s) async => await _submitLink(), ); if (widget.link == null) { @@ -182,25 +145,64 @@ class _DirectLinkFieldState extends State { child = Padding( key: const Key('Editing'), padding: const EdgeInsets.only(top: 8.0), - child: Obx(() { - final bool deletable = !_state.isEmpty.value && - _state.error.value == null && - _state.text.isNotEmpty; - - return ReactiveTextField( - key: const Key('LinkField'), - state: _state, - clearable: true, - onSuffixPressed: deletable - ? () async { - await widget.onSubmit?.call(null); - setState(() => _editing = false); - } - : null, - trailing: deletable ? const SvgIcon(SvgIcons.delete) : null, - label: '${Config.link}/', - ); - }), + child: Column( + children: [ + Obx(() { + final bool deletable = !_state.isEmpty.value && + _state.error.value == null && + _state.text.isNotEmpty; + + return ReactiveTextField( + key: const Key('LinkField'), + state: _state, + clearable: true, + onSuffixPressed: deletable + ? () async { + await widget.onSubmit?.call(null); + setState(() => _editing = false); + } + : null, + trailing: deletable ? const SvgIcon(SvgIcons.delete) : null, + label: '${Config.link}/', + ); + }), + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 16), + MouseRegion( + cursor: SystemMouseCursors.click, + child: WidgetButton( + onPressed: () { + if (widget.link != null) { + _state.text = widget.link?.slug.val ?? _state.text; + } + setState(() => _editing = false); + widget.onEditing?.call(_editing); + }, + child: Text( + 'btn_cancel'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + const Spacer(), + MouseRegion( + cursor: SystemMouseCursors.click, + child: WidgetButton( + onPressed: _submitLink, + child: Text( + 'btn_save'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + const SizedBox(width: 16), + ], + ), + ], + ), ); } else if (widget.link == null) { child = Column( @@ -370,22 +372,43 @@ class _DirectLinkFieldState extends State { ], ), const SizedBox(height: 12), - WidgetButton( - onPressed: () { - final share = '${Config.link}/${_state.text}'; - - if (PlatformUtils.isMobile) { - Share.share(share); - } else { - PlatformUtils.copy(text: share); - MessagePopup.success('label_copied'.l10n); - } - }, - child: Text( - PlatformUtils.isMobile ? 'btn_share'.l10n : 'btn_copy'.l10n, - style: style.fonts.small.regular.primary, - ), - ) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 16), + WidgetButton( + onPressed: () { + final share = '${Config.link}/${_state.text}'; + + if (PlatformUtils.isMobile) { + Share.share(share); + } else { + PlatformUtils.copy(text: share); + MessagePopup.success('label_copied'.l10n); + } + }, + child: Text( + PlatformUtils.isMobile ? 'btn_share'.l10n : 'btn_copy'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + const Spacer(), + MouseRegion( + cursor: SystemMouseCursors.click, + child: WidgetButton( + onPressed: () { + setState(() => _editing = true); + widget.onEditing?.call(_editing); + }, + child: Text( + 'btn_edit'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + const SizedBox(width: 16), + ], + ), ], ); } @@ -420,4 +443,43 @@ class _DirectLinkFieldState extends State { ), ); } + + /// Submits the [_state] as the new [ChatDirectLinkSlug]. + Future _submitLink() async { + final ChatDirectLinkSlug? slug = ChatDirectLinkSlug.tryParse(_state.text); + + if (_state.text.isNotEmpty) { + if (slug == null) { + _state.error.value = 'err_invalid_symbols_in_link'.l10n; + } + + if (slug == null || slug == widget.link?.slug) { + setState(() => _editing = false); + widget.onEditing?.call(_editing); + return; + } + } + + if (_state.error.value == null) { + _state.editable.value = false; + _state.status.value = RxStatus.loading(); + + try { + await widget.onSubmit?.call(slug); + _state.status.value = RxStatus.success(); + await Future.delayed(const Duration(seconds: 1)); + _state.status.value = RxStatus.empty(); + } on CreateChatDirectLinkException catch (e) { + _state.status.value = RxStatus.empty(); + _state.error.value = e.toMessage(); + } catch (e) { + _state.status.value = RxStatus.empty(); + _state.error.value = 'err_data_transfer'.l10n; + _state.unsubmit(); + rethrow; + } finally { + _state.editable.value = true; + } + } + } } diff --git a/lib/ui/page/home/widget/quick_button.dart b/lib/ui/page/home/widget/quick_button.dart deleted file mode 100644 index 5651571b4eb..00000000000 --- a/lib/ui/page/home/widget/quick_button.dart +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:flutter/material.dart'; - -import '/themes.dart'; -import '/ui/widget/svg/svg.dart'; -import '/ui/widget/widget_button.dart'; - -/// [WidgetButton] of squared [Container] with an [icon] and a [label]. -class QuickButton extends StatelessWidget { - const QuickButton({ - super.key, - required this.icon, - required this.label, - this.onPressed, - }); - - /// [SvgData] to display as an icon. - final SvgData icon; - - /// Label to display. - final String label; - - /// Callback, called when this button is pressed. - final void Function()? onPressed; - - @override - Widget build(BuildContext context) { - final style = Theme.of(context).style; - - return WidgetButton( - onPressed: onPressed, - child: Container( - height: 60, - decoration: BoxDecoration( - color: style.cardColor, - border: style.cardBorder, - borderRadius: style.cardRadius, - ), - child: Center( - child: Transform.translate( - offset: const Offset(0, 1), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgIcon(icon), - const SizedBox(height: 6), - Padding( - padding: const EdgeInsets.fromLTRB(2, 0, 2, 0), - child: FittedBox( - child: Text( - label, - style: style.fonts.small.regular.primary, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} From b373eab217c58652363b6c850ec88f8388b4a694 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 23 Apr 2024 17:57:48 +0300 Subject: [PATCH 46/88] Add `BotElement` --- lib/ui/page/home/page/chat/controller.dart | 29 +++++++++++++++++++++- lib/ui/page/home/page/chat/view.dart | 18 ++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 4660841889f..fed111af355 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -784,7 +784,7 @@ class ChatController extends GetxController { if (_lastVisibleItem != null && status.value.isSuccess && !status.value.isLoadingMore) { - ListElement element = + final ListElement element = elements.values.elementAt(_lastVisibleItem!.index); // If the [_lastVisibleItem] is posted after the [_lastSeenItem], @@ -927,6 +927,24 @@ class ChatController extends GetxController { } } + if (chat?.chat.value.isDialog ?? false) { + final recipient = chat!.chat.value.members.any( + (e) => + e.user.id != me && + (e.user.name?.val == 'alex2' || + e.user.name?.val == 'Alex' || + e.user.name?.val == 'nikita'), + ); + + if (recipient) { + final element = BotElement( + 'Translated dialog. English - Russian. Translation cost: 99I per 100 symbols.', + at: PreciseDateTime.now(), + ); + elements[element.id] = element; + } + } + SchedulerBinding.instance.addPostFrameCallback((_) { _ensureScrollable(); }); @@ -2305,6 +2323,15 @@ class LoaderElement extends ListElement { ); } +/// [ListElement] representing a [ChatInfo]. +class BotElement extends ListElement { + BotElement(this.string, {required PreciseDateTime at}) + : super(ListElementId(at, const ChatItemId('0'))); + + /// [ChatItem] of this [BotElement]. + final String string; +} + /// Extension adding [ChatView] related wrappers and helpers. extension ChatViewExt on Chat { /// Returns text represented title of this [Chat]. diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 81492379222..3aaad2518c4 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1077,6 +1077,24 @@ class ChatView extends StatelessWidget { child: child, ); }); + } else if (element is BotElement) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.colors.accept, + width: 1, + ), + color: style.systemMessageColor, + ), + child: Text(element.string, style: style.systemMessageStyle), + ), + ), + ); } return const SizedBox(); From 39878ba5dcf20c990b5ccbd83acfea7be8a4f15f Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 24 Apr 2024 18:24:37 +0300 Subject: [PATCH 47/88] Display symbols counter above `MessageFieldView` --- lib/ui/page/home/page/chat/controller.dart | 33 ++++-- .../page/chat/message_field/controller.dart | 2 + .../home/page/chat/message_field/view.dart | 105 ++++++++++++------ lib/ui/page/home/page/chat/view.dart | 38 ++++++- 4 files changed, 130 insertions(+), 48 deletions(-) diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index fed111af355..33c04172438 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -719,6 +719,8 @@ class ChatController extends GetxController { ); } + bool hasBot = false; + /// Fetches the local [chat] value from [_chatService] by the provided [id]. Future _fetchChat() async { ISentrySpan span = _ready.startChild('fetch'); @@ -928,7 +930,7 @@ class ChatController extends GetxController { } if (chat?.chat.value.isDialog ?? false) { - final recipient = chat!.chat.value.members.any( + hasBot = chat!.chat.value.members.any( (e) => e.user.id != me && (e.user.name?.val == 'alex2' || @@ -936,12 +938,18 @@ class ChatController extends GetxController { e.user.name?.val == 'nikita'), ); - if (recipient) { - final element = BotElement( - 'Translated dialog. English - Russian. Translation cost: 99I per 100 symbols.', + if (hasBot) { + final info = BotInfoElement( + 'Translated dialog. English - Russian. Translation cost: \$1.110681 (€0.99) per 100 symbols.', at: PreciseDateTime.now(), ); - elements[element.id] = element; + elements[info.id] = info; + + final action = BotActionElement( + 'Submit', + at: PreciseDateTime.now().add(const Duration(milliseconds: 1)), + ); + elements[action.id] = action; } } @@ -2324,11 +2332,20 @@ class LoaderElement extends ListElement { } /// [ListElement] representing a [ChatInfo]. -class BotElement extends ListElement { - BotElement(this.string, {required PreciseDateTime at}) +class BotInfoElement extends ListElement { + BotInfoElement(this.string, {required PreciseDateTime at}) + : super(ListElementId(at, const ChatItemId('0'))); + + /// [String] of this [BotInfoElement]. + final String string; +} + +/// [ListElement] representing a [ChatInfo]. +class BotActionElement extends ListElement { + BotActionElement(this.string, {required PreciseDateTime at}) : super(ListElementId(at, const ChatItemId('0'))); - /// [ChatItem] of this [BotElement]. + /// [String] of this [BotActionElement]. final String string; } diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index ee846e567b5..048dc0dd9cd 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -198,6 +198,8 @@ class MessageFieldController extends GetxController { /// [GlobalKey] of the text field itself. final GlobalKey fieldKey = GlobalKey(); + final RxInt symbols = RxInt(0); + /// [ChatButton]s displayed in the more panel. late final RxList panel = RxList([ const AudioMessageButton(), diff --git a/lib/ui/page/home/page/chat/message_field/view.dart b/lib/ui/page/home/page/chat/message_field/view.dart index 825893afc7f..4581ddf9b6c 100644 --- a/lib/ui/page/home/page/chat/message_field/view.dart +++ b/lib/ui/page/home/page/chat/message_field/view.dart @@ -54,7 +54,7 @@ import 'widget/chat_button.dart'; import 'widget/close_button.dart'; /// View for writing and editing a [ChatMessage] or a [ChatForward]. -class MessageFieldView extends StatelessWidget { +class MessageFieldView extends StatefulWidget { const MessageFieldView({ super.key, this.controller, @@ -66,6 +66,7 @@ class MessageFieldView extends StatelessWidget { this.canForward = false, this.canAttach = true, this.constraints, + this.symbols = false, }); /// Optionally provided external [MessageFieldController]. @@ -96,6 +97,8 @@ class MessageFieldView extends StatelessWidget { /// [BoxConstraints] replies, attachments and quotes are allowed to occupy. final BoxConstraints? constraints; + final bool symbols; + /// Returns a [ThemeData] to decorate a [ReactiveTextField] with. static ThemeData theme(BuildContext context) { final style = Theme.of(context).style; @@ -130,41 +133,68 @@ class MessageFieldView extends StatelessWidget { ); } + @override + State createState() => _MessageFieldViewState(); +} + +class _MessageFieldViewState extends State { + final GlobalKey _sizeKey = GlobalKey(); + final GlobalKey _backdropKey = GlobalKey(); + @override Widget build(BuildContext context) { final style = Theme.of(context).style; return GetBuilder( - init: controller ?? + init: widget.controller ?? MessageFieldController(Get.find(), Get.find(), Get.find()), global: false, builder: (MessageFieldController c) { return Theme( - data: theme(context), + data: MessageFieldView.theme(context), child: SafeArea( - child: Container( - key: const Key('SendField'), - decoration: BoxDecoration( - borderRadius: style.cardRadius, - boxShadow: [ - CustomBoxShadow( - blurRadius: 8, - color: style.colors.onBackgroundOpacity13, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.symbols) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 12, 4), + child: Text( + 'Symbols: ${c.symbols.value}', + style: style.fonts.small.regular.secondary, + ), + ), + ), + Container( + key: const Key('SendField'), + decoration: BoxDecoration( + borderRadius: style.cardRadius, + boxShadow: [ + CustomBoxShadow( + blurRadius: 8, + color: style.colors.onBackgroundOpacity13, + ), + ], + ), + child: ConditionalBackdropFilter( + condition: style.cardBlur > 0, + filter: ImageFilter.blur( + sigmaX: style.cardBlur, + sigmaY: style.cardBlur, + ), + borderRadius: style.cardRadius, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(c, context), + _buildField(c, context), + ], + ), ), - ], - ), - child: ConditionalBackdropFilter( - condition: style.cardBlur > 0, - filter: ImageFilter.blur( - sigmaX: style.cardBlur, - sigmaY: style.cardBlur, - ), - borderRadius: style.cardRadius, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [_buildHeader(c, context), _buildField(c, context)], ), - ), + ], ), ), ); @@ -321,7 +351,7 @@ class MessageFieldView extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: WidgetButton( - onPressed: () => onItemPressed?.call(e.value), + onPressed: () => widget.onItemPressed?.call(e.value), child: _buildPreview( context, e.value, @@ -339,6 +369,7 @@ class MessageFieldView extends StatelessWidget { } return ConditionalBackdropFilter( + key: _backdropKey, condition: style.cardBlur > 0, filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), borderRadius: BorderRadius.only( @@ -348,6 +379,7 @@ class MessageFieldView extends StatelessWidget { child: Container( color: style.colors.onPrimaryOpacity50, child: AnimatedSize( + key: _sizeKey, duration: 400.milliseconds, alignment: Alignment.bottomCenter, curve: Curves.ease, @@ -365,7 +397,8 @@ class MessageFieldView extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: WidgetButton( - onPressed: () => onItemPressed?.call(c.edited.value!), + onPressed: () => + widget.onItemPressed?.call(c.edited.value!), child: _buildPreview( context, c.edited.value!, @@ -377,7 +410,7 @@ class MessageFieldView extends StatelessWidget { ), if (previews != null) ConstrainedBox( - constraints: this.constraints ?? + constraints: widget.constraints ?? BoxConstraints( maxHeight: max( 100, @@ -417,7 +450,7 @@ class MessageFieldView extends StatelessWidget { ), ), ), - ] + ], ], ), ), @@ -443,7 +476,7 @@ class MessageFieldView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ WidgetButton( - onPressed: canAttach ? c.toggleMore : null, + onPressed: widget.canAttach ? c.toggleMore : null, child: AnimatedButton( child: SizedBox( width: 50, @@ -470,8 +503,11 @@ class MessageFieldView extends StatelessWidget { child: Transform.translate( offset: Offset(0, PlatformUtils.isMobile ? 6 : 1), child: ReactiveTextField( - onChanged: onChanged, - key: fieldKey ?? const Key('MessageField'), + onChanged: () { + c.symbols.value = c.field.text.length; + widget.onChanged?.call(); + }, + key: widget.fieldKey ?? const Key('MessageField'), state: c.field, hint: 'label_send_message_hint'.l10n, minLines: 1, @@ -508,10 +544,11 @@ class MessageFieldView extends StatelessWidget { child: ChatButtonWidget.send( key: c.forwarding.value ? const Key('Forward') - : sendKey ?? const Key('Send'), + : widget.sendKey ?? const Key('Send'), forwarding: c.forwarding.value, onPressed: c.field.submit, - onLongPress: canForward ? c.forwarding.toggle : null, + onLongPress: + widget.canForward ? c.forwarding.toggle : null, ), ); }) @@ -856,7 +893,7 @@ class MessageFieldView extends StatelessWidget { width: 30, borderRadius: BorderRadius.circular(4), onForbidden: () async => - await onAttachmentError?.call(item), + await widget.onAttachmentError?.call(item), ), ); }).toList(), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 3aaad2518c4..3780a4e5fb0 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1077,24 +1077,49 @@ class ChatView extends StatelessWidget { child: child, ); }); - } else if (element is BotElement) { + } else if (element is BotInfoElement) { + const Color color = Color.fromARGB(255, 149, 209, 149); + return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), - border: Border.all( - color: style.colors.accept, - width: 1, - ), + border: Border.all(color: color, width: 0.5), color: style.systemMessageColor, ), child: Text(element.string, style: style.systemMessageStyle), ), ), ); + } else if (element is BotActionElement) { + const Color color = Color.fromARGB(255, 149, 209, 149); + + return SelectionContainer.disabled( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Center( + child: WidgetButton( + onPressed: () {}, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: style.systemMessageColor, width: 1), + color: color, + ), + child: Text( + element.string, + style: style.fonts.small.regular.onPrimary, + ), + ), + ), + ), + ), + ); } return const SizedBox(); @@ -1432,6 +1457,7 @@ class ChatView extends StatelessWidget { c.animateTo(item.id, item: item, addToHistory: false), canForward: true, onAttachmentError: c.chat?.updateAttachments, + symbols: c.hasBot, ); }); } From a9ca8a4e3b0407d3107835b92c3b548a64c20b8f Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 25 Apr 2024 18:52:12 +0300 Subject: [PATCH 48/88] Improve minor UI/UX and impl `ChatCommand`s --- assets/l10n/ru-RU.ftl | 2 +- lib/api/backend/extension/chat.dart | 15 ++ lib/domain/model/chat_item.dart | 58 +++++ lib/domain/model_type_id.dart | 5 + lib/provider/hive/chat_item.dart | 20 ++ lib/store/chat.dart | 1 + lib/ui/page/home/page/chat/controller.dart | 52 +++- .../page/chat/message_field/controller.dart | 8 +- lib/ui/page/home/page/chat/view.dart | 37 +++ .../page/home/page/chat/widget/chat_item.dart | 235 +++++++++++++++++- .../page/chat/widget/message_timestamp.dart | 113 +++++---- 11 files changed, 470 insertions(+), 76 deletions(-) diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 1737c88e1b1..afbb73d7314 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -619,7 +619,7 @@ label_avatar_updated = {$author} обновил аватар label_avatar_updated1 = {$author} label_avatar_updated2 = {" "}обновил аватар label_away = отошёл -label_background = Бэкграунд +label_background = Фон label_besides_freelance_is_an_option_too1 = Кроме того, предусмотрена возможность сотрудничества в качестве фриланс разработчика. Со списком задач и условиями сотрудничества можно ознакомится на странице{" "} label_besides_freelance_is_an_option_too2 = Freelance. label_block = Заблокировать diff --git a/lib/api/backend/extension/chat.dart b/lib/api/backend/extension/chat.dart index 6d830a516dc..ec58d2b6ef3 100644 --- a/lib/api/backend/extension/chat.dart +++ b/lib/api/backend/extension/chat.dart @@ -154,6 +154,21 @@ extension ChatMessageConversion on ChatMessageMixin { HiveChatItem toHive(ChatItemsCursor cursor) { List items = repliesTo.map((e) => e.toHive()).toList(); + // if (text?.val.startsWith('/') ?? false) { + // return HiveChatCommand( + // ChatCommand( + // id, + // chatId, + // author.toModel(), + // at, + // repliesTo: items.firstOrNull?.value, + // text: text, + // ), + // cursor, + // ver, + // ); + // } + return HiveChatMessage( ChatMessage( id, diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 8bd333f0958..1c7d86af69f 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -231,3 +231,61 @@ class ChatItemKey implements Comparable { @override int get hashCode => Object.hash(id, at); } + +/// Command in a [Chat]. +@HiveType(typeId: ModelTypeId.chatCommand) +class ChatCommand extends ChatItem { + ChatCommand( + super.id, + super.chatId, + super.author, + super.at, { + super.status, + this.repliesTo, + this.text, + }); + + @HiveField(5) + ChatItemQuote? repliesTo; + + @HiveField(6) + ChatMessageText? text; +} + +/// Command in a [Chat]. +@HiveType(typeId: ModelTypeId.botInfo) +class BotInfo extends ChatItem { + BotInfo( + super.id, + super.chatId, + super.author, + super.at, { + super.status, + this.repliesTo, + this.text, + this.actions, + }); + + @HiveField(5) + ChatItemQuote? repliesTo; + + @HiveField(6) + ChatMessageText? text; + + @HiveField(7) + List? actions; +} + +@HiveType(typeId: ModelTypeId.botAction) +class BotAction { + BotAction({ + required this.text, + required this.command, + }); + + @HiveField(0) + String text; + + @HiveField(1) + String command; +} diff --git a/lib/domain/model_type_id.dart b/lib/domain/model_type_id.dart index cb2ce1157a5..8521bd8b340 100644 --- a/lib/domain/model_type_id.dart +++ b/lib/domain/model_type_id.dart @@ -129,4 +129,9 @@ class ModelTypeId { static const refreshTokenSecret = 106; static const accessToken = 107; static const accessTokenSecret = 108; + static const chatCommand = 109; + static const botInfo = 110; + static const botAction = 111; + static const hiveChatCommand = 112; + static const hiveBotInfo = 113; } diff --git a/lib/provider/hive/chat_item.dart b/lib/provider/hive/chat_item.dart index 372349a4106..b1420cc7d69 100644 --- a/lib/provider/hive/chat_item.dart +++ b/lib/provider/hive/chat_item.dart @@ -240,3 +240,23 @@ class HiveChatItemQuote { @HiveField(1) ChatItemsCursor? cursor; } + +/// Persisted in [Hive] storage [ChatInfo]'s [value]. +@HiveType(typeId: ModelTypeId.hiveChatCommand) +class HiveChatCommand extends HiveChatItem { + HiveChatCommand( + super.value, + super.cursor, + super.ver, + ); +} + +/// Persisted in [Hive] storage [ChatInfo]'s [value]. +@HiveType(typeId: ModelTypeId.hiveBotInfo) +class HiveBotInfo extends HiveChatItem { + HiveBotInfo( + super.value, + super.cursor, + super.ver, + ); +} diff --git a/lib/store/chat.dart b/lib/store/chat.dart index 484d5e77e6f..61fac46c586 100644 --- a/lib/store/chat.dart +++ b/lib/store/chat.dart @@ -866,6 +866,7 @@ class ChatRepository extends DisposableInterface ); } + throw Exception(); var response = await _graphQlProvider.uploadAttachment( upload, onSendProgress: (now, max) => attachment.progress.value = now / max, diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 33c04172438..12b12b9c17a 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -17,6 +17,7 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:convert'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:collection/collection.dart'; @@ -215,6 +216,9 @@ class ChatController extends GetxController { /// Height of a [LoaderElement] displayed in the message list. static const double loaderHeight = 64; + final RxBool showCommands = RxBool(false); + final RxBool botEnabled = RxBool(false); + /// [ListElementId] of an item from the [elements] that should be highlighted. final Rx highlighted = Rx(null); @@ -930,7 +934,7 @@ class ChatController extends GetxController { } if (chat?.chat.value.isDialog ?? false) { - hasBot = chat!.chat.value.members.any( + botEnabled.value = chat!.chat.value.members.any( (e) => e.user.id != me && (e.user.name?.val == 'alex2' || @@ -938,18 +942,19 @@ class ChatController extends GetxController { e.user.name?.val == 'nikita'), ); - if (hasBot) { + if (botEnabled.value) { final info = BotInfoElement( 'Translated dialog. English - Russian. Translation cost: \$1.110681 (€0.99) per 100 symbols.', at: PreciseDateTime.now(), ); elements[info.id] = info; - final action = BotActionElement( - 'Submit', - at: PreciseDateTime.now().add(const Duration(milliseconds: 1)), - ); - elements[action.id] = action; + // final action = BotActionElement( + // 'Submit', + // at: PreciseDateTime.now().add(const Duration(milliseconds: 1)), + // ); + + // elements[action.id] = action; } } @@ -1022,6 +1027,39 @@ class ChatController extends GetxController { } } + Future postCommand(String command, {ChatItem? repliesTo}) async { + await _chatService.sendChatMessage( + id, + text: ChatMessageText(command), + repliesTo: [if (repliesTo != null) repliesTo], + ); + + if (command == '/translate' && repliesTo is ChatMessage) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText( + '[@bot]${jsonEncode( + { + 'text': + 'Detected language: English. Translation to Russian costs \$1.1 per 100 symbols.\nYour message contains ${repliesTo.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (repliesTo.text?.val.length ?? 0)}', + 'actions': [ + { + 'text': 'Pay and proceed', + 'command': '/proceed', + }, + { + 'text': 'Change language', + 'command': '/language', + } + ], + }, + )}', + ), + repliesTo: [repliesTo], + ); + } + } + /// Animates [listController] to a [ChatItem] identified by the provided /// [item] and its [reply] or [forward]. Future animateTo( diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index 048dc0dd9cd..a3fb544b427 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -202,10 +202,10 @@ class MessageFieldController extends GetxController { /// [ChatButton]s displayed in the more panel. late final RxList panel = RxList([ - const AudioMessageButton(), - const VideoMessageButton(), - const DonateButton(), - const StickerButton(), + // const AudioMessageButton(), + // const VideoMessageButton(), + // const DonateButton(), + // const StickerButton(), if (PlatformUtils.isMobile && !PlatformUtils.isWeb) ...[ TakePhotoButton(pickImageFromCamera), if (PlatformUtils.isAndroid) TakeVideoButton(pickVideoFromCamera), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 3780a4e5fb0..0959a4f2f1a 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -347,6 +347,20 @@ class ChatView extends StatelessWidget { right: 10, ), actions: [ + ContextMenuButton( + label: 'Bot: ${c.botEnabled.value}', + onPressed: () { + c.botEnabled.toggle(); + c.elements.refresh(); + }, + ), + ContextMenuButton( + label: 'Debug: ${c.showCommands.value}', + onPressed: () { + c.showCommands.toggle(); + c.elements.refresh(); + }, + ), if (c.callPosition == CallButtonsPosition.contextMenu) ...[ ContextMenuButton( @@ -807,6 +821,19 @@ class ChatView extends StatelessWidget { Rx e; if (element is ChatMessageElement) { + if ((element.item.value as ChatMessage).text?.val.startsWith('/') ?? + false) { + if (!c.showCommands.value) { + return Padding( + padding: EdgeInsets.only( + top: previousSame || previous is UnreadMessagesElement ? 0 : 9, + bottom: isLast ? ChatController.lastItemBottomOffset : 0, + ), + child: const SizedBox(), + ); + } + } + e = element.item; } else if (element is ChatCallElement) { e = element.item; @@ -889,6 +916,16 @@ class ChatView extends StatelessWidget { c.selecting.toggle(); c.selected.add(element); }, + actions: [ + if (c.botEnabled.value) + ContextMenuButton( + onPressed: () => c.postCommand( + '/translate', + repliesTo: e.value, + ), + label: 'Translate', + ), + ], ), ), ); diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 6a5c163c46f..e698b21027e 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -16,6 +16,7 @@ // . import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; @@ -26,6 +27,8 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../../../../domain/service/chat.dart'; +import '../../../../../widget/outlined_rounded_button.dart'; import '../controller.dart' show ChatCallFinishReasonL10n, ChatController; import '/api/backend/schema.dart' show ChatCallFinishReason; import '/config.dart'; @@ -92,6 +95,7 @@ class ChatItemWidget extends StatefulWidget { this.onDownloadAs, this.onSave, this.onSelect, + this.actions = const [], }); /// Reactive value of a [ChatItem] to display. @@ -166,6 +170,8 @@ class ChatItemWidget extends StatefulWidget { /// Callback, called when a select action is triggered. final void Function()? onSelect; + final List actions; + @override State createState() => _ChatItemWidgetState(); @@ -297,12 +303,7 @@ class ChatItemWidget extends StatefulWidget { ), ), ) - : Icon( - Icons.error, - key: const Key('Error'), - size: 48, - color: style.colors.danger, - ), + : const SizedBox(key: Key('Error')), ), ) ], @@ -371,6 +372,9 @@ class _ChatItemWidgetState extends State { /// [_text] and [_galleryKeys]. Worker? _worker; + ChatCommand? _command; + BotInfo? _bot; + /// Indicates whether this [ChatItem] was read by any [User]. bool get _isRead { final Chat? chat = widget.chat.value; @@ -436,6 +440,14 @@ class _ChatItemWidgetState extends State { style: style.fonts.medium.regular.onBackground, child: Obx(() { if (widget.item.value is ChatMessage) { + if (_command != null) { + return _renderAsChatCommand(context); + } + + if (_bot != null) { + return _renderAsBotInfo(context); + } + return _renderAsChatMessage(context); } else if (widget.item.value is ChatForward) { throw Exception( @@ -745,6 +757,170 @@ class _ChatItemWidgetState extends State { ); } + Widget _renderAsChatCommand(BuildContext context) { + final style = Theme.of(context).style; + + final ChatMessage msg = widget.item.value as ChatMessage; + final ChatCommand command = _command!; + + Widget replied(ChatItemQuote e) { + final FutureOr? user = widget.getUser?.call(e.author); + + return FutureBuilder( + future: user is Future ? user : null, + builder: (_, snapshot) { + final RxUser? data = snapshot.data ?? (user is RxUser? ? user : null); + + final Color color = data?.user.value.id == widget.me + ? style.colors.primary + : style.colors.userColors[(data?.user.value.num.val.sum() ?? 3) % + style.colors.userColors.length]; + + if (e is ChatMessageQuote) { + return Container( + decoration: const BoxDecoration( + border: Border(left: BorderSide(color: Colors.blue)), + ), + padding: const EdgeInsets.all(2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data?.title ?? 'dot'.l10n * 3, + style: style.fonts.smallest.regular.onBackground + .copyWith(color: color), + ), + if (e.text != null) + Text( + e.text!.val, + style: style.fonts.smallest.regular.onBackground, + ), + ], + ), + ); + } + + return const SizedBox(); + }, + ); + } + + return _rounded( + context, + (_, constraints) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white.withOpacity(0.4), + ), + padding: const EdgeInsets.all(2), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ...msg.repliesTo.map( + (e) { + return WidgetButton( + onPressed: () => widget.onRepliedTap?.call(e), + child: replied(e), + ); + }, + ), + Text( + '/${command.text}', + style: style.fonts.smallest.regular.onBackground, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _renderAsBotInfo(BuildContext context) { + final style = Theme.of(context).style; + + final ChatMessage msg = widget.item.value as ChatMessage; + final BotInfo info = _bot!; + + const Color color = Color.fromARGB(255, 149, 209, 149); + + return _rounded( + context, + (_, constraints) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: color, width: 0.5), + color: style.systemMessageColor, + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...msg.repliesTo.map( + (e) { + return WidgetButton( + onPressed: () => widget.onRepliedTap?.call(e), + child: _repliedMessage(e, constraints), + ); + }, + ), + Text('${info.text}', style: style.systemMessageStyle), + if (info.actions != null) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 2, + runSpacing: 2, + children: info.actions!.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () async { + final ChatService chatService = Get.find(); + await chatService.sendChatMessage( + msg.chatId, + text: ChatMessageText(e.command), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.systemMessageColor, + width: 1, + ), + color: color, + ), + child: Text( + e.text, + style: style.fonts.small.regular.onPrimary, + ), + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + } + /// Renders [widget.item] as [ChatMessage]. Widget _renderAsChatMessage(BuildContext context) { final style = Theme.of(context).style; @@ -1423,7 +1599,7 @@ class _ChatItemWidgetState extends State { ? null : (d) { if (_draggingStarted && !_dragging) { - if (_offset.dx == 0 && d.delta.dx > 0) { + if (_offset.dx == 0 && d.delta.dx < 0) { _dragging = true; } else { _draggingStarted = false; @@ -1433,13 +1609,13 @@ class _ChatItemWidgetState extends State { if (_dragging) { // Distance [_totalOffset] should exceed in order for // dragging to start. - const int delta = 10; + const int delta = -10; - if (_totalOffset.dx > delta) { + if (_totalOffset.dx < delta) { _offset += d.delta; - if (_offset.dx > 30 + delta && - _offset.dx - d.delta.dx < 30 + delta) { + if (_offset.dx < -30 + delta && + _offset.dx - d.delta.dx > -30 + delta) { HapticFeedback.selectionClick(); widget.onReply?.call(); } @@ -1507,6 +1683,7 @@ class _ChatItemWidgetState extends State { ? Alignment.bottomRight : Alignment.bottomLeft, actions: [ + ...widget.actions, ContextMenuButton( label: PlatformUtils.isMobile ? 'btn_info'.l10n @@ -1730,6 +1907,7 @@ class _ChatItemWidgetState extends State { widget.chat.value?.lastDelivery.isBefore(item.at) == false || isMonolog, inverted: inverted, + onPressed: item.status.value == SendingStatus.error ? () {} : null, ), ); }); @@ -1749,6 +1927,41 @@ class _ChatItemWidgetState extends State { final msg = widget.item.value as ChatMessage; attachments = msg.attachments.length; text = msg.text; + + if (text?.val.startsWith('/') ?? false) { + _command = ChatCommand( + msg.id, + msg.chatId, + msg.author, + msg.at, + repliesTo: msg.repliesTo.firstOrNull, + text: ChatMessageText(text!.val.substring(1)), + ); + } else if (text?.val.startsWith('[@bot]') ?? false) { + Map? decoded; + + try { + decoded = jsonDecode(text!.val.substring('[@bot]'.length)); + } catch (_) { + // No-op. + } + + if (decoded != null) { + _bot = BotInfo( + msg.id, + msg.chatId, + msg.author, + msg.at, + text: decoded['text'] == null + ? null + : ChatMessageText(decoded['text']), + repliesTo: msg.repliesTo.firstOrNull, + actions: (decoded['actions'] as List?)?.map((e) { + return BotAction(text: e['text'], command: e['command']); + }).toList(), + ); + } + } } _worker = ever(widget.item, (ChatItem item) { diff --git a/lib/ui/page/home/page/chat/widget/message_timestamp.dart b/lib/ui/page/home/page/chat/widget/message_timestamp.dart index 9f9a3f3bfb3..16ff27ac4e6 100644 --- a/lib/ui/page/home/page/chat/widget/message_timestamp.dart +++ b/lib/ui/page/home/page/chat/widget/message_timestamp.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; +import '../../../../../widget/widget_button.dart'; import '/domain/model/precise_date_time/precise_date_time.dart'; import '/domain/model/sending_status.dart'; import '/l10n/l10n.dart'; @@ -36,6 +37,7 @@ class MessageTimestamp extends StatelessWidget { this.delivered = false, this.inverted = false, this.fontSize, + this.onPressed, }); /// [PreciseDateTime] to display in this [MessageTimestamp]. @@ -66,6 +68,8 @@ class MessageTimestamp extends StatelessWidget { /// Optional font size of this [MessageTimestamp]. final double? fontSize; + final void Function()? onPressed; + @override Widget build(BuildContext context) { final style = Theme.of(context).style; @@ -77,63 +81,66 @@ class MessageTimestamp extends StatelessWidget { final bool isError = status == SendingStatus.error; final bool isSending = status == SendingStatus.sending; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SelectionContainer.disabled( - child: Text( - date ? at.val.toLocal().yMdHm : at.val.toLocal().hm, - style: (inverted - ? style.fonts.smaller.regular.onPrimary - : style.fonts.smaller.regular.secondary) - .copyWith( - fontSize: - fontSize ?? style.fonts.smaller.regular.onBackground.fontSize, + return WidgetButton( + onPressed: onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SelectionContainer.disabled( + child: Text( + date ? at.val.toLocal().yMdHm : at.val.toLocal().hm, + style: (inverted + ? style.fonts.smaller.regular.onPrimary + : style.fonts.smaller.regular.secondary) + .copyWith( + fontSize: fontSize ?? + style.fonts.smaller.regular.onBackground.fontSize, + ), ), ), - ), - if (status != null && - (isSent || isDelivered || isRead || isSending || isError)) ...[ - const SizedBox(width: 3), - SizedBox( - key: Key( - isError - ? 'Error' - : isSending - ? 'Sending' - : isRead - ? isHalfRead - ? 'HalfRead' - : 'Read' - : 'Sent', - ), - width: 17, - child: SvgIcon( - isRead - ? isHalfRead - ? inverted - ? SvgIcons.halfReadWhite - : SvgIcons.halfRead - : inverted - ? SvgIcons.readWhite - : SvgIcons.read - : isDelivered - ? inverted - ? SvgIcons.deliveredWhite - : SvgIcons.delivered - : isError - ? SvgIcons.error - : isSending - ? inverted - ? SvgIcons.sendingWhite - : SvgIcons.sending - : inverted - ? SvgIcons.sentWhite - : SvgIcons.sent, + if (status != null && + (isSent || isDelivered || isRead || isSending || isError)) ...[ + const SizedBox(width: 3), + SizedBox( + key: Key( + isError + ? 'Error' + : isSending + ? 'Sending' + : isRead + ? isHalfRead + ? 'HalfRead' + : 'Read' + : 'Sent', + ), + width: 17, + child: SvgIcon( + isRead + ? isHalfRead + ? inverted + ? SvgIcons.halfReadWhite + : SvgIcons.halfRead + : inverted + ? SvgIcons.readWhite + : SvgIcons.read + : isDelivered + ? inverted + ? SvgIcons.deliveredWhite + : SvgIcons.delivered + : isError + ? SvgIcons.error + : isSending + ? inverted + ? SvgIcons.sendingWhite + : SvgIcons.sending + : inverted + ? SvgIcons.sentWhite + : SvgIcons.sent, + ), ), - ), + ], ], - ], + ), ); } } From 4adf7f3d817784ada7ee612907cbc2d83b55381b Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 26 Apr 2024 18:15:38 +0300 Subject: [PATCH 49/88] Embed `BotInfo` displaying into `ChatMessage`s --- lib/domain/model/chat_item.dart | 36 +++ lib/ui/page/home/page/chat/controller.dart | 107 ++++++--- lib/ui/page/home/page/chat/view.dart | 7 + .../page/home/page/chat/widget/chat_item.dart | 219 +++++++++++++----- 4 files changed, 280 insertions(+), 89 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 1c7d86af69f..392159307f7 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -15,6 +15,8 @@ // along with this program. If not, see // . +import 'dart:convert'; + import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:uuid/uuid.dart'; @@ -262,10 +264,41 @@ class BotInfo extends ChatItem { super.at, { super.status, this.repliesTo, + required this.title, this.text, this.actions, }); + static BotInfo? parse(ChatMessage msg) { + if (msg.text?.val.startsWith('[@bot]') ?? false) { + Map? decoded; + + try { + decoded = jsonDecode(msg.text!.val.substring('[@bot]'.length)); + } catch (_) { + // No-op. + } + + if (decoded != null) { + return BotInfo( + msg.id, + msg.chatId, + msg.author, + msg.at, + text: + decoded['text'] == null ? null : ChatMessageText(decoded['text']), + repliesTo: msg.repliesTo.firstOrNull, + actions: (decoded['actions'] as List?)?.map((e) { + return BotAction(text: e['text'], command: e['command']); + }).toList(), + title: decoded['title'] ?? 'Bot', + ); + } + } + + return null; + } + @HiveField(5) ChatItemQuote? repliesTo; @@ -274,6 +307,9 @@ class BotInfo extends ChatItem { @HiveField(7) List? actions; + + @HiveField(8) + String title; } @HiveType(typeId: ModelTypeId.botAction) diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 12b12b9c17a..ba4eddec2a9 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -217,7 +217,7 @@ class ChatController extends GetxController { static const double loaderHeight = 64; final RxBool showCommands = RxBool(false); - final RxBool botEnabled = RxBool(false); + final RxBool botEnabled = RxBool(true); /// [ListElementId] of an item from the [elements] that should be highlighted. final Rx highlighted = Rx(null); @@ -1034,29 +1034,60 @@ class ChatController extends GetxController { repliesTo: [if (repliesTo != null) repliesTo], ); - if (command == '/translate' && repliesTo is ChatMessage) { - await _chatService.sendChatMessage( - id, - text: ChatMessageText( - '[@bot]${jsonEncode( - { - 'text': - 'Detected language: English. Translation to Russian costs \$1.1 per 100 symbols.\nYour message contains ${repliesTo.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (repliesTo.text?.val.length ?? 0)}', - 'actions': [ - { - 'text': 'Pay and proceed', - 'command': '/proceed', - }, - { - 'text': 'Change language', - 'command': '/language', - } - ], - }, - )}', - ), - repliesTo: [repliesTo], - ); + if (repliesTo is ChatMessage) { + if (command == '/translate') { + await _chatService.sendChatMessage( + id, + text: ChatMessageText( + '[@bot]${jsonEncode( + { + 'title': 'Translation', + 'text': + 'Detected: English. Your message contains ${repliesTo.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (repliesTo.text?.val.length ?? 0)}', + 'actions': [ + { + 'text': 'Order translation', + 'command': '/proceed', + }, + { + 'text': 'Change language', + 'command': '/language', + } + ], + }, + )}', + ), + repliesTo: [repliesTo], + ); + } else if (command == '/proceed') { + await _chatService.sendChatMessage( + id, + text: ChatMessageText( + '[@bot]${jsonEncode( + { + 'title': 'Translation', + 'text': 'Translating... 💭', + }, + )}', + ), + repliesTo: [repliesTo], + ); + + await Future.delayed(const Duration(seconds: 5)); + + await _chatService.sendChatMessage( + id, + text: ChatMessageText( + '[@bot]${jsonEncode( + { + 'title': 'Translation', + 'text': 'Translated ✅', + }, + )}', + ), + repliesTo: [repliesTo], + ); + } } } @@ -1765,6 +1796,21 @@ class ChatController extends GetxController { next.note.value == null) { insert = false; next.note.value = e; + } else { + final BotInfo? info = BotInfo.parse(item); + final ChatItemQuote? quote = info?.repliesTo; + + if (quote != null) { + insert = false; + + final items = + elements.entries.where((e) => e.key.id == quote.original?.id); + for (var e in items.map((e) => e.value)) { + if (e is ChatMessageElement) { + e.infos[info!.title] = info; + } + } + } } if (insert) { @@ -2292,11 +2338,20 @@ abstract class ListElement { /// [ListElement] representing a [ChatMessage]. class ChatMessageElement extends ListElement { - ChatMessageElement(this.item) - : super(ListElementId(item.value.at, item.value.id)); + ChatMessageElement( + this.item, { + List infos = const [], + }) : infos = RxMap.from( + Map.fromEntries( + infos.map((e) => MapEntry(e.title, e)).toList(), + ), + ), + super(ListElementId(item.value.at, item.value.id)); /// [ChatItem] of this [ChatMessageElement]. final Rx item; + + final RxMap infos; } /// [ListElement] representing a [ChatCall]. diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 0959a4f2f1a..877638f15e9 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -916,6 +916,10 @@ class ChatView extends StatelessWidget { c.selecting.toggle(); c.selected.add(element); }, + onAction: (b) => c.postCommand( + b.command, + repliesTo: e.value, + ), actions: [ if (c.botEnabled.value) ContextMenuButton( @@ -926,6 +930,9 @@ class ChatView extends StatelessWidget { label: 'Translate', ), ], + infos: element is ChatMessageElement + ? element.infos.values.toList() + : [], ), ), ); diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index e698b21027e..292eb8b6e4b 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -96,6 +96,8 @@ class ChatItemWidget extends StatefulWidget { this.onSave, this.onSelect, this.actions = const [], + this.infos = const [], + this.onAction, }); /// Reactive value of a [ChatItem] to display. @@ -172,6 +174,10 @@ class ChatItemWidget extends StatefulWidget { final List actions; + final List infos; + + final Function(BotAction)? onAction; + @override State createState() => _ChatItemWidgetState(); @@ -839,9 +845,89 @@ class _ChatItemWidgetState extends State { ); } + Widget _botInfo(BuildContext context, BotInfo e) { + final style = Theme.of(context).style; + + final ChatMessage msg = widget.item.value as ChatMessage; + + const Color color = Color.fromARGB(255, 149, 209, 149); + + final InputBorder border = OutlineInputBorder( + borderSide: BorderSide( + color: style.primaryBorder.top.color, + width: style.primaryBorder.top.width, + ), + borderRadius: BorderRadius.circular(15), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + child: InputDecorator( + decoration: InputDecoration( + label: Text( + e.title, + // style: style.fonts.small.regular.secondary, + ), + floatingLabelStyle: style.fonts.small.regular.secondary, + filled: true, + fillColor: Colors.white.withOpacity(0.34), + floatingLabelAlignment: FloatingLabelAlignment.center, + focusedBorder: border, + errorBorder: border, + enabledBorder: border, + disabledBorder: border, + focusedErrorBorder: border, + contentPadding: const EdgeInsets.fromLTRB(8, 16, 8, 8), + isCollapsed: true, + // contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + border: border, + ), + child: Column( + children: [ + Text('${e.text}', style: style.fonts.smallest.regular.secondary), + if (e.actions != null) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 2, + runSpacing: 2, + children: e.actions!.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () => widget.onAction?.call(e), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.systemMessageColor, + width: 1, + ), + color: color, + ), + child: Text( + e.text, + style: style.fonts.smallest.regular.onPrimary, + ), + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ); + } + Widget _renderAsBotInfo(BuildContext context) { final style = Theme.of(context).style; + // return const SizedBox(); + final ChatMessage msg = widget.item.value as ChatMessage; final BotInfo info = _bot!; @@ -1137,48 +1223,54 @@ class _ChatItemWidgetState extends State { return Container( padding: const EdgeInsets.fromLTRB(5, 0, 2, 0), - child: Stack( - children: [ - IntrinsicWidth( - child: AnimatedContainer( - duration: const Duration(milliseconds: 500), - decoration: BoxDecoration( - color: _fromMe - ? _isRead - ? style.readMessageColor - : style.unreadMessageColor - : style.messageColor, - borderRadius: BorderRadius.circular(15), - border: _fromMe - ? _isRead - ? style.secondaryBorder - : Border.all( - color: style.readMessageColor, - width: 0.5, + child: IntrinsicWidth( + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + color: _fromMe + ? _isRead + ? style.readMessageColor + : style.unreadMessageColor + : style.messageColor, + borderRadius: BorderRadius.circular(15), + border: _fromMe + ? _isRead + ? style.secondaryBorder + : Border.all( + color: style.readMessageColor, + width: 0.5, + ) + : style.primaryBorder, + ), + child: Column( + children: [ + Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + Positioned( + right: timeInBubble ? 6 : 8, + bottom: 4, + child: timeInBubble + ? Container( + padding: + const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + color: style.colors.onBackgroundOpacity50, + borderRadius: BorderRadius.circular(20), + ), + child: _timestamp(msg, true), ) - : style.primaryBorder, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, + : _timestamp(msg), + ) + ], ), - ), + ...widget.infos.map((e) => _botInfo(context, e)), + ], ), - Positioned( - right: timeInBubble ? 6 : 8, - bottom: 4, - child: timeInBubble - ? Container( - padding: const EdgeInsets.only(left: 4, right: 4), - decoration: BoxDecoration( - color: style.colors.onBackgroundOpacity50, - borderRadius: BorderRadius.circular(20), - ), - child: _timestamp(msg, true), - ) - : _timestamp(msg), - ) - ], + ), ), ); }, @@ -1937,31 +2029,32 @@ class _ChatItemWidgetState extends State { repliesTo: msg.repliesTo.firstOrNull, text: ChatMessageText(text!.val.substring(1)), ); - } else if (text?.val.startsWith('[@bot]') ?? false) { - Map? decoded; - - try { - decoded = jsonDecode(text!.val.substring('[@bot]'.length)); - } catch (_) { - // No-op. - } - - if (decoded != null) { - _bot = BotInfo( - msg.id, - msg.chatId, - msg.author, - msg.at, - text: decoded['text'] == null - ? null - : ChatMessageText(decoded['text']), - repliesTo: msg.repliesTo.firstOrNull, - actions: (decoded['actions'] as List?)?.map((e) { - return BotAction(text: e['text'], command: e['command']); - }).toList(), - ); - } } + // else if (text?.val.startsWith('[@bot]') ?? false) { + // Map? decoded; + + // try { + // decoded = jsonDecode(text!.val.substring('[@bot]'.length)); + // } catch (_) { + // // No-op. + // } + + // if (decoded != null) { + // _bot = BotInfo( + // msg.id, + // msg.chatId, + // msg.author, + // msg.at, + // text: decoded['text'] == null + // ? null + // : ChatMessageText(decoded['text']), + // repliesTo: msg.repliesTo.firstOrNull, + // actions: (decoded['actions'] as List?)?.map((e) { + // return BotAction(text: e['text'], command: e['command']); + // }).toList(), + // ); + // } + // } } _worker = ever(widget.item, (ChatItem item) { From 1f3732b04bfe9ee4193e396cc6accde0687665c1 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 29 Apr 2024 18:32:30 +0300 Subject: [PATCH 50/88] Improve bot design and add `add participant` to `Chat` page --- assets/icons/add_contact.svg | 2 +- assets/icons/add_contact_white.svg | 2 +- assets/icons/add_participant.svg | 1 + assets/icons/add_participant_white.svg | 1 + assets/icons/delete_contact.svg | 2 +- assets/icons/delete_contact_white.svg | 2 +- lib/domain/model/chat.dart | 2 + lib/domain/model/user.dart | 2 + lib/domain/repository/chat.dart | 4 + lib/domain/repository/user.dart | 2 + lib/store/chat_rx.dart | 13 ++ lib/ui/page/home/page/chat/controller.dart | 52 ++++--- .../page/chat/info/add_member/controller.dart | 25 +++- .../page/home/page/chat/info/controller.dart | 28 ++++ lib/ui/page/home/page/chat/info/view.dart | 22 +++ lib/ui/page/home/page/chat/view.dart | 36 +++-- lib/ui/page/home/page/user/controller.dart | 37 +++++ lib/ui/page/home/page/user/view.dart | 131 +++++++++++++++++- .../home/tab/chats/widget/recent_chat.dart | 8 ++ lib/ui/page/home/widget/contact_tile.dart | 1 + .../style/page/widgets/common/dummy_chat.dart | 17 +++ lib/ui/widget/member_tile.dart | 77 +++++----- lib/ui/widget/svg/svgs.dart | 28 ++-- 23 files changed, 407 insertions(+), 88 deletions(-) create mode 100644 assets/icons/add_participant.svg create mode 100644 assets/icons/add_participant_white.svg diff --git a/assets/icons/add_contact.svg b/assets/icons/add_contact.svg index 838250beaa9..eb5673d3141 100644 --- a/assets/icons/add_contact.svg +++ b/assets/icons/add_contact.svg @@ -1 +1 @@ - + diff --git a/assets/icons/add_contact_white.svg b/assets/icons/add_contact_white.svg index 1e707002738..4d5a5461fd3 100644 --- a/assets/icons/add_contact_white.svg +++ b/assets/icons/add_contact_white.svg @@ -1 +1 @@ - + diff --git a/assets/icons/add_participant.svg b/assets/icons/add_participant.svg new file mode 100644 index 00000000000..1950af549a9 --- /dev/null +++ b/assets/icons/add_participant.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/add_participant_white.svg b/assets/icons/add_participant_white.svg new file mode 100644 index 00000000000..765cdcadb45 --- /dev/null +++ b/assets/icons/add_participant_white.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/delete_contact.svg b/assets/icons/delete_contact.svg index 0046b38cf68..f6829c4133f 100644 --- a/assets/icons/delete_contact.svg +++ b/assets/icons/delete_contact.svg @@ -1 +1 @@ - + diff --git a/assets/icons/delete_contact_white.svg b/assets/icons/delete_contact_white.svg index e7b4480ac70..a04048277e8 100644 --- a/assets/icons/delete_contact_white.svg +++ b/assets/icons/delete_contact_white.svg @@ -1 +1 @@ - + diff --git a/lib/domain/model/chat.dart b/lib/domain/model/chat.dart index e2ee7170e18..572f36da5b0 100644 --- a/lib/domain/model/chat.dart +++ b/lib/domain/model/chat.dart @@ -177,6 +177,8 @@ class Chat extends HiveObject implements Comparable { /// Indicates whether this [Chat] is a group. bool get isGroup => kind == ChatKind.group; + bool get isBot => isDialog && members.any((e) => e.user.bio?.val == 'bot'); + /// Returns an [UserAvatar] of this [Chat]. UserAvatar? getUserAvatar(UserId? me) { switch (kind) { diff --git a/lib/domain/model/user.dart b/lib/domain/model/user.dart index ff0e83fd3a2..d565dd14dd9 100644 --- a/lib/domain/model/user.dart +++ b/lib/domain/model/user.dart @@ -152,6 +152,8 @@ class User extends HiveObject { String get title => contacts.firstOrNull?.name.val ?? name?.val ?? num.toString(); + bool get isBot => bio?.val == 'bot'; + @override String toString() => '$runtimeType($id)'; } diff --git a/lib/domain/repository/chat.dart b/lib/domain/repository/chat.dart index 34e34f3cf4c..0cba946a86b 100644 --- a/lib/domain/repository/chat.dart +++ b/lib/domain/repository/chat.dart @@ -299,6 +299,10 @@ abstract class RxChat implements Comparable { /// Indicates whether this [chat] has an [OngoingCall] active on this device. RxBool get inCall; + RxList get bots; + Future addBot(RxUser user); + Future removeBot(RxUser user); + /// Fetches the [Paginated] page around the [item], if specified, or /// [messages] around the [firstUnread] otherwise. /// diff --git a/lib/domain/repository/user.dart b/lib/domain/repository/user.dart index 68dd0c0a5f1..e6c5a4a8269 100644 --- a/lib/domain/repository/user.dart +++ b/lib/domain/repository/user.dart @@ -82,4 +82,6 @@ abstract class RxUser { /// Listens to the updates of this [RxUser] while the returned [Stream] is /// listened to. Stream get updates; + + bool get isBot => user.value.isBot; } diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index 74f5bda3d21..46fd4869d55 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -976,6 +976,19 @@ class HiveRxChat extends RxChat { }); } + @override + final RxList bots = RxList(); + + @override + Future addBot(RxUser user) async { + bots.add(user); + } + + @override + Future removeBot(RxUser user) async { + bots.remove(user); + } + @override int compareTo(RxChat other) => chat.value.compareTo(other.chat.value, me); diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index ba4eddec2a9..109fa0a0ce1 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -217,7 +217,7 @@ class ChatController extends GetxController { static const double loaderHeight = 64; final RxBool showCommands = RxBool(false); - final RxBool botEnabled = RxBool(true); + // final RxBool botEnabled = RxBool(true); /// [ListElementId] of an item from the [elements] that should be highlighted. final Rx highlighted = Rx(null); @@ -723,8 +723,6 @@ class ChatController extends GetxController { ); } - bool hasBot = false; - /// Fetches the local [chat] value from [_chatService] by the provided [id]. Future _fetchChat() async { ISentrySpan span = _ready.startChild('fetch'); @@ -933,30 +931,30 @@ class ChatController extends GetxController { } } - if (chat?.chat.value.isDialog ?? false) { - botEnabled.value = chat!.chat.value.members.any( - (e) => - e.user.id != me && - (e.user.name?.val == 'alex2' || - e.user.name?.val == 'Alex' || - e.user.name?.val == 'nikita'), - ); - - if (botEnabled.value) { - final info = BotInfoElement( - 'Translated dialog. English - Russian. Translation cost: \$1.110681 (€0.99) per 100 symbols.', - at: PreciseDateTime.now(), - ); - elements[info.id] = info; - - // final action = BotActionElement( - // 'Submit', - // at: PreciseDateTime.now().add(const Duration(milliseconds: 1)), - // ); - - // elements[action.id] = action; - } - } + // if (chat?.chat.value.isDialog ?? false) { + // botEnabled.value = chat!.chat.value.members.any( + // (e) => + // e.user.id != me && + // (e.user.name?.val == 'alex2' || + // e.user.name?.val == 'Alex' || + // e.user.name?.val == 'nikita'), + // ); + + // if (botEnabled.value) { + // final info = BotInfoElement( + // 'Translated dialog. English - Russian. Translation cost: \$1.110681 (€0.99) per 100 symbols.', + // at: PreciseDateTime.now(), + // ); + // elements[info.id] = info; + + // // final action = BotActionElement( + // // 'Submit', + // // at: PreciseDateTime.now().add(const Duration(milliseconds: 1)), + // // ); + + // // elements[action.id] = action; + // } + // } SchedulerBinding.instance.addPostFrameCallback((_) { _ensureScrollable(); diff --git a/lib/ui/page/home/page/chat/info/add_member/controller.dart b/lib/ui/page/home/page/chat/info/add_member/controller.dart index b8a2f2f07a1..04c00fe3f3e 100644 --- a/lib/ui/page/home/page/chat/info/add_member/controller.dart +++ b/lib/ui/page/home/page/chat/info/add_member/controller.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'package:get/get.dart'; +import '../../../../../../../routes.dart'; import '/api/backend/schema.graphql.dart' show AddChatMemberErrorCode; import '/domain/model/chat.dart'; import '/domain/model/user.dart'; @@ -110,7 +111,29 @@ class AddChatMemberController extends GetxController { Future addMembers(List ids) async { status.value = RxStatus.loading(); - pop?.call(); + if (chat.value?.chat.value.isGroup == false) { + if (ids.length == 1) { + final user = await _userService.get(ids.first); + if (user?.isBot == true) { + chat.value?.addBot(user!); + return; + } + } + + try { + chat.value = await _chatService.createGroupChat(ids); + pop?.call(); + + router.chat(chat.value!.id); + } catch (e) { + MessagePopup.error(e); + rethrow; + } finally { + status.value = RxStatus.empty(); + } + + return; + } try { final Iterable futures = ids.map( diff --git a/lib/ui/page/home/page/chat/info/controller.dart b/lib/ui/page/home/page/chat/info/controller.dart index e5e0734794d..8a8b1a9a0e8 100644 --- a/lib/ui/page/home/page/chat/info/controller.dart +++ b/lib/ui/page/home/page/chat/info/controller.dart @@ -22,6 +22,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:messenger/domain/model/mute_duration.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -427,6 +428,33 @@ class ChatInfoController extends GetxController { } } + /// Mutes the [chat]. + Future muteChat() async { + try { + await _chatService.toggleChatMute( + chat?.id ?? chatId, + MuteDuration.forever(), + ); + } on ToggleChatMuteException catch (e) { + MessagePopup.error(e); + } catch (e) { + MessagePopup.error(e); + rethrow; + } + } + + /// Unmutes the [chat]. + Future unmuteChat() async { + try { + await _chatService.toggleChatMute(chat?.id ?? chatId, null); + } on ToggleChatMuteException catch (e) { + MessagePopup.error(e); + } catch (e) { + MessagePopup.error(e); + rethrow; + } + } + /// Removes the specified [User] from a [OngoingCall] happening in the [chat]. Future removeChatCallMember(UserId userId) async { try { diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index da9efbcf1c9..2109d6689ed 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -19,6 +19,7 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:messenger/util/platform_utils.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '/config.dart'; @@ -474,6 +475,7 @@ class ChatInfoView extends StatelessWidget { final Widget editButton = Obx(key: const Key('MoreButton'), () { final bool favorite = c.chat?.chat.value.favoritePosition != null; final bool hasCall = c.chat?.chat.value.ongoingCall != null; + final bool muted = c.chat?.chat.value.muted != null; return ContextMenuRegion( key: c.moreKey, @@ -524,6 +526,26 @@ class ChatInfoView extends StatelessWidget { : SvgIcons.unfavoriteSmallWhite, ), ), + if (!c.isMonolog) + ContextMenuButton( + key: Key( + muted ? 'UnmuteChatButton' : 'MuteChatButton', + ), + label: muted + ? PlatformUtils.isMobile + ? 'btn_unmute'.l10n + : 'btn_unmute_chat'.l10n + : PlatformUtils.isMobile + ? 'btn_mute'.l10n + : 'btn_mute_chat'.l10n, + trailing: SvgIcon( + muted ? SvgIcons.unmuteSmall : SvgIcons.muteSmall, + ), + inverted: SvgIcon( + muted ? SvgIcons.unmuteSmallWhite : SvgIcons.muteSmallWhite, + ), + onPressed: muted ? c.unmuteChat : c.muteChat, + ), if (!c.isMonolog) ContextMenuButton( onPressed: () => _reportChat(c, context), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 877638f15e9..80e9d5ed6c2 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -57,6 +57,7 @@ import '/util/message_popup.dart'; import '/util/platform_utils.dart'; import 'controller.dart'; import 'forward/view.dart'; +import 'info/add_member/controller.dart'; import 'message_field/controller.dart'; import 'widget/back_button.dart'; import 'widget/chat_forward.dart'; @@ -347,13 +348,13 @@ class ChatView extends StatelessWidget { right: 10, ), actions: [ - ContextMenuButton( - label: 'Bot: ${c.botEnabled.value}', - onPressed: () { - c.botEnabled.toggle(); - c.elements.refresh(); - }, - ), + // ContextMenuButton( + // label: 'Bot: ${c.botEnabled.value}', + // onPressed: () { + // c.botEnabled.toggle(); + // c.elements.refresh(); + // }, + // ), ContextMenuButton( label: 'Debug: ${c.showCommands.value}', onPressed: () { @@ -361,6 +362,20 @@ class ChatView extends StatelessWidget { c.elements.refresh(); }, ), + ContextMenuButton( + label: 'btn_add_participant'.l10n, + onPressed: () async => + await AddChatMemberView.show( + context, + chatId: id, + ), + trailing: const SvgIcon( + SvgIcons.addParticipant, + ), + inverted: const SvgIcon( + SvgIcons.addParticipantWhite, + ), + ), if (c.callPosition == CallButtonsPosition.contextMenu) ...[ ContextMenuButton( @@ -921,7 +936,10 @@ class ChatView extends StatelessWidget { repliesTo: e.value, ), actions: [ - if (c.botEnabled.value) + // if (c.botEnabled.value) + if (c.chat?.bots + .any((e) => e.title == 'Translation Service') == + true) ContextMenuButton( onPressed: () => c.postCommand( '/translate', @@ -1501,7 +1519,7 @@ class ChatView extends StatelessWidget { c.animateTo(item.id, item: item, addToHistory: false), canForward: true, onAttachmentError: c.chat?.updateAttachments, - symbols: c.hasBot, + // symbols: c.hasBot, ); }); } diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index d17df5c67f8..ff4cff6af1c 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -21,6 +21,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:messenger/domain/service/my_user.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -51,6 +52,8 @@ import '/provider/gql/exceptions.dart' FavoriteChatContactException, HideChatException, JoinChatCallException, + RedialChatCallMemberException, + RemoveChatCallMemberException, ToggleChatMuteException, UnfavoriteChatContactException, UnfavoriteChatException, @@ -70,6 +73,7 @@ class UserController extends GetxController { this._contactService, this._chatService, this._callService, + this._myUserService, ); /// ID of the [User] this [UserController] represents. @@ -140,6 +144,8 @@ class UserController extends GetxController { /// [CallService] starting a new [OngoingCall] with this [user]. final CallService _callService; + final MyUserService _myUserService; + /// [Worker] reacting on the [RxUser.contact] changes updating the [_worker]. Worker? _contactWorker; @@ -175,6 +181,8 @@ class UserController extends GetxController { /// Only meaningful, if [user] is non-`null`. Rx get contact => user!.contact; + Rx get myUser => _myUserService.myUser; + /// Returns [ChatContactId] of the [contact]. /// /// Should be used to determine whether the [user] is in the contacts list, as @@ -288,6 +296,35 @@ class UserController extends GetxController { /// Drops the [OngoingCall] happening in the [RxUser.dialog]. Future dropCall() => _callService.leave(user!.user.value.dialog); + /// Redials the [User] identified by its [userId]. + Future redialChatCallMember(UserId userId) async { + if (userId == me) { + await _callService.join(user!.user.value.dialog); + return; + } + + try { + await _callService.redialChatCallMember(user!.user.value.dialog, userId); + } on RedialChatCallMemberException catch (e) { + MessagePopup.error(e); + } catch (e) { + MessagePopup.error(e); + rethrow; + } + } + + /// Removes the specified [User] from a [OngoingCall] happening in the [chat]. + Future removeChatCallMember(UserId userId) async { + try { + await _callService.removeChatCallMember(user!.user.value.dialog, userId); + } on RemoveChatCallMemberException catch (e) { + MessagePopup.error(e); + } catch (e) { + MessagePopup.error(e); + rethrow; + } + } + /// Blocks the [user] for the authenticated [MyUser]. Future block() async { blocklistStatus.value = RxStatus.loading(); diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 62fe43fc75b..13c52399d84 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -19,6 +19,11 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:messenger/config.dart'; +import 'package:messenger/domain/model/my_user.dart'; +import 'package:messenger/domain/repository/user.dart'; +import 'package:messenger/ui/page/home/page/chat/info/add_member/view.dart'; +import 'package:messenger/ui/widget/member_tile.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '/domain/model/chat.dart'; @@ -59,7 +64,14 @@ class UserView extends StatelessWidget { @override Widget build(BuildContext context) { return GetBuilder( - init: UserController(id, Get.find(), Get.find(), Get.find(), Get.find()), + init: UserController( + id, + Get.find(), + Get.find(), + Get.find(), + Get.find(), + Get.find(), + ), tag: id.val, global: !Get.isRegistered(tag: id.val), builder: (UserController c) { @@ -100,6 +112,14 @@ class UserView extends StatelessWidget { highlighted(index: 0, child: _profile(c, context)), _bio(c, context), _info(c), + Obx(() { + final RxUser user = c.user!; + if (user.dialog.value != null) { + return _members(c, context); + } + + return const SizedBox(); + }), SelectionContainer.disabled( child: Block(children: [_actions(c, context)]), ), @@ -266,6 +286,115 @@ class UserView extends StatelessWidget { ); } + /// Returns the [Block] displaying the [Chat.members]. + Widget _members(UserController c, BuildContext context) { + final style = Theme.of(context).style; + final RxChat dialog = c.user!.dialog.value!; + + return Block( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), + title: 'label_participants'.l10nfmt({'count': 2}), + children: [ + Obx(() { + final List members = []; + + for (var u in dialog.members.values) { + if (u.user.id != c.me) { + members.add(u.user); + } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 500), + child: ListView.builder( + key: const Key('ChatMembers'), + shrinkWrap: true, + itemCount: members.length + 1, + itemBuilder: (_, i) { + i--; + + Widget child; + + final bool hasCall = dialog.chat.value.ongoingCall != null; + + if (i == -1) { + final MyUser? myUser = c.myUser.value; + final bool inCall = dialog.inCall.value == true; + + child = MemberTile( + myUser: myUser, + inCall: hasCall ? inCall : null, + onCall: inCall + ? () { + if (myUser != null) { + c.removeChatCallMember(myUser.id); + } + } + : c.joinCall, + ); + } else { + final RxUser member = members[i]; + + final bool inCall = dialog.chat.value.ongoingCall?.members + .any((u) => u.user.id == member.id) == + true; + + child = MemberTile( + user: member, + inCall: hasCall ? inCall : null, + onTap: () => + router.chat(member.user.value.dialog, push: true), + onCall: inCall + ? () => c.removeChatCallMember(member.id) + : () => c.redialChatCallMember(member.id), + ); + } + + child = Padding( + padding: const EdgeInsets.only(right: 10, left: 10), + child: child, + ); + + if (i == members.length - 1 && + dialog.members.hasNext.value) { + child = Column( + children: [ + child, + CustomProgressIndicator( + key: const Key('MembersLoading'), + value: Config.disableInfiniteAnimations ? 0 : null, + ) + ], + ); + } + + return child; + }, + ), + ), + ], + ); + }), + const SizedBox(height: 16), + SelectionContainer.disabled( + child: WidgetButton( + onPressed: () async => await AddChatMemberView.show( + context, + chatId: dialog.id, + ), + child: Text( + 'btn_add_participant'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + ], + ); + } + /// Returns information about the [User] and related to it action buttons in /// the [CustomAppBar]. Widget _bar(UserController c, BuildContext context) { diff --git a/lib/ui/page/home/tab/chats/widget/recent_chat.dart b/lib/ui/page/home/tab/chats/widget/recent_chat.dart index df367e371b5..efdfc6f1959 100644 --- a/lib/ui/page/home/tab/chats/widget/recent_chat.dart +++ b/lib/ui/page/home/tab/chats/widget/recent_chat.dart @@ -191,6 +191,14 @@ class RecentChatTile extends StatelessWidget { dimmed: blocked, status: [ const SizedBox(height: 28), + if (chat.isBot == true) ...[ + Icon( + Icons.smart_toy, + size: 20, + color: + inverted ? style.colors.onPrimary : style.colors.secondary, + ), + ], if (trailing == null) ...[ _ongoingCall(context, inverted: inverted), if (blocked) ...[ diff --git a/lib/ui/page/home/widget/contact_tile.dart b/lib/ui/page/home/widget/contact_tile.dart index 68b0e6978a6..89c5683e308 100644 --- a/lib/ui/page/home/widget/contact_tile.dart +++ b/lib/ui/page/home/widget/contact_tile.dart @@ -192,6 +192,7 @@ class ContactTile extends StatelessWidget { ], ), ), + if (user?.isBot == true) const Text('Bot'), ...trailing, ], ), diff --git a/lib/ui/page/style/page/widgets/common/dummy_chat.dart b/lib/ui/page/style/page/widgets/common/dummy_chat.dart index ca11bbdcf02..ade4c437399 100644 --- a/lib/ui/page/style/page/widgets/common/dummy_chat.dart +++ b/lib/ui/page/style/page/widgets/common/dummy_chat.dart @@ -17,6 +17,7 @@ import 'package:get/get.dart'; +import '../../../../../../domain/repository/user.dart'; import '/domain/model/attachment.dart'; import '/domain/model/avatar.dart'; import '/domain/model/chat.dart'; @@ -133,4 +134,20 @@ class DummyRxChat extends RxChat { @override int compareTo(RxChat other) => 0; + + @override + Future addBot(RxUser user) { + // TODO: implement addBot + throw UnimplementedError(); + } + + @override + // TODO: implement bots + RxList get bots => RxList(); + + @override + Future removeBot(RxUser user) { + // TODO: implement removeBot + throw UnimplementedError(); + } } diff --git a/lib/ui/widget/member_tile.dart b/lib/ui/widget/member_tile.dart index 49f37e19c9a..3db7e1a48d6 100644 --- a/lib/ui/widget/member_tile.dart +++ b/lib/ui/widget/member_tile.dart @@ -101,47 +101,48 @@ class MemberTile extends StatelessWidget { ), ), ), - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 41), - child: Align( - alignment: Alignment.centerRight, - child: AnimatedButton( - enabled: !_me, - decorator: (child) => Padding( - padding: const EdgeInsets.all(12), - child: child, - ), - onPressed: _me - ? null - : () async { - final bool? result = await MessagePopup.alert( - 'label_remove_member'.l10n, - description: [ - TextSpan(text: 'alert_user_will_be_removed1'.l10n), - TextSpan( - text: user?.title, - style: style.fonts.normal.regular.onBackground, - ), - TextSpan(text: 'alert_user_will_be_removed2'.l10n), - ], - ); + if (_me || onKick != null) + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 41), + child: Align( + alignment: Alignment.centerRight, + child: AnimatedButton( + enabled: !_me, + decorator: (child) => Padding( + padding: const EdgeInsets.all(12), + child: child, + ), + onPressed: _me + ? null + : () async { + final bool? result = await MessagePopup.alert( + 'label_remove_member'.l10n, + description: [ + TextSpan(text: 'alert_user_will_be_removed1'.l10n), + TextSpan( + text: user?.title, + style: style.fonts.normal.regular.onBackground, + ), + TextSpan(text: 'alert_user_will_be_removed2'.l10n), + ], + ); - if (result == true) { - await onKick?.call(); - } - }, - child: _me - ? Text( - 'label_you'.l10n, - style: style.fonts.normal.regular.secondary, - ) - : const SvgIcon( - SvgIcons.delete, - key: Key('DeleteMemberButton'), - ), + if (result == true) { + await onKick?.call(); + } + }, + child: _me + ? Text( + 'label_you'.l10n, + style: style.fonts.normal.regular.secondary, + ) + : const SvgIcon( + SvgIcons.delete, + key: Key('DeleteMemberButton'), + ), + ), ), ), - ), const SizedBox(width: 6), ], ); diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index db8b2c900a0..75001351002 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1529,26 +1529,26 @@ class SvgIcons { static const SvgData addContact = SvgData( 'assets/icons/add_contact.svg', - width: 21.01, - height: 19.43, + width: 21.13, + height: 19.93, ); static const SvgData addContactWhite = SvgData( 'assets/icons/add_contact_white.svg', - width: 21.01, - height: 19.43, + width: 21.13, + height: 19.93, ); static const SvgData deleteContact = SvgData( 'assets/icons/delete_contact.svg', - width: 21.01, - height: 19.43, + width: 20.25, + height: 19.05, ); static const SvgData deleteContactWhite = SvgData( 'assets/icons/delete_contact_white.svg', - width: 21.01, - height: 19.43, + width: 20.25, + height: 19.05, ); static const SvgData group = SvgData( @@ -1892,4 +1892,16 @@ class SvgIcons { width: 32, height: 32, ); + + static const SvgData addParticipant = SvgData( + 'assets/icons/add_participant.svg', + width: 21.48, + height: 21, + ); + + static const SvgData addParticipantWhite = SvgData( + 'assets/icons/add_participant_white.svg', + width: 21.48, + height: 21, + ); } From 3909c40729724c0c63571144f232b295208af2e2 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 29 Apr 2024 18:34:04 +0300 Subject: [PATCH 51/88] Fix `pop` not called --- lib/ui/page/home/page/chat/info/add_member/controller.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ui/page/home/page/chat/info/add_member/controller.dart b/lib/ui/page/home/page/chat/info/add_member/controller.dart index 04c00fe3f3e..faf4a837ca7 100644 --- a/lib/ui/page/home/page/chat/info/add_member/controller.dart +++ b/lib/ui/page/home/page/chat/info/add_member/controller.dart @@ -116,6 +116,7 @@ class AddChatMemberController extends GetxController { final user = await _userService.get(ids.first); if (user?.isBot == true) { chat.value?.addBot(user!); + pop?.call(); return; } } From f8720093a447c7de45129126d87a618d05fd8cdd Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 30 Apr 2024 10:49:57 +0300 Subject: [PATCH 52/88] Use `profile=yes` for `build` CI job --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e74f64c6eea..b8f74d27218 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,7 +236,7 @@ jobs: # TODO: Use `split-debug-info` for Windows once Sentry supports it: # https://github.com/getsentry/sentry-dart/issues/433 # https://github.com/getsentry/sentry-dart/issues/896 - - run: make flutter.build platform=${{ matrix.platform }} + - run: make flutter.build platform=${{ matrix.platform }} profile=yes dart-env='SOCAPP_HTTP_URL=${{ secrets.BACKEND_URL }} SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} @@ -381,7 +381,7 @@ jobs: # TODO: Use `split-debug-info` when Sentry supports Linux debug symbols. # https://github.com/getsentry/sentry-dart/issues/433 - - run: make flutter.build platform=linux + - run: make flutter.build platform=linux profile=yes dart-env='SOCAPP_HTTP_URL=${{ secrets.BACKEND_URL }} SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} From 353efbb58d290564c69ec33a0bc07cda20ee645a Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 30 Apr 2024 18:12:41 +0300 Subject: [PATCH 53/88] Allow adding/removing bots to `Chat` and display its about --- lib/domain/model/chat.dart | 6 ++- lib/domain/model/user.dart | 2 +- lib/domain/repository/chat.dart | 2 +- lib/store/chat_rx.dart | 6 +-- lib/ui/page/home/page/chat/controller.dart | 41 +++++++++++++++++++ lib/ui/page/home/page/user/controller.dart | 5 ++- lib/ui/page/home/page/user/view.dart | 9 ++++ lib/ui/page/home/widget/contact_tile.dart | 8 +++- .../style/page/widgets/common/dummy_chat.dart | 3 +- 9 files changed, 72 insertions(+), 10 deletions(-) diff --git a/lib/domain/model/chat.dart b/lib/domain/model/chat.dart index 572f36da5b0..70b878bdc66 100644 --- a/lib/domain/model/chat.dart +++ b/lib/domain/model/chat.dart @@ -177,7 +177,11 @@ class Chat extends HiveObject implements Comparable { /// Indicates whether this [Chat] is a group. bool get isGroup => kind == ChatKind.group; - bool get isBot => isDialog && members.any((e) => e.user.bio?.val == 'bot'); + bool get isBot => + isDialog && + members.any( + (e) => e.user.bio?.val.startsWith('bot') == true, + ); /// Returns an [UserAvatar] of this [Chat]. UserAvatar? getUserAvatar(UserId? me) { diff --git a/lib/domain/model/user.dart b/lib/domain/model/user.dart index d565dd14dd9..73c3802a825 100644 --- a/lib/domain/model/user.dart +++ b/lib/domain/model/user.dart @@ -152,7 +152,7 @@ class User extends HiveObject { String get title => contacts.firstOrNull?.name.val ?? name?.val ?? num.toString(); - bool get isBot => bio?.val == 'bot'; + bool get isBot => bio?.val.startsWith('bot') == true; @override String toString() => '$runtimeType($id)'; diff --git a/lib/domain/repository/chat.dart b/lib/domain/repository/chat.dart index 0cba946a86b..26581d44210 100644 --- a/lib/domain/repository/chat.dart +++ b/lib/domain/repository/chat.dart @@ -299,7 +299,7 @@ abstract class RxChat implements Comparable { /// Indicates whether this [chat] has an [OngoingCall] active on this device. RxBool get inCall; - RxList get bots; + RxObsList get bots; Future addBot(RxUser user); Future removeBot(RxUser user); diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index 46fd4869d55..edda7084dd6 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -977,11 +977,11 @@ class HiveRxChat extends RxChat { } @override - final RxList bots = RxList(); + final RxObsList bots = RxObsList(); @override Future addBot(RxUser user) async { - bots.add(user); + bots.addIf(!bots.contains(user), user); } @override @@ -1171,7 +1171,7 @@ class HiveRxChat extends RxChat { } if (members.rawLength == chat.value.membersCount) { - members.pagination?.hasNext.value = false; + members.pagination?.hasNext.value = false; // TODO: Still displayed? members.pagination?.hasPrevious.value = false; members.status.value = RxStatus.success(); } diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 109fa0a0ce1..df2064e6e38 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -351,6 +351,8 @@ class ChatController extends GetxController { /// [Paginated] of [ChatItem]s to display in the [elements]. Paginated>? _fragment; + StreamSubscription? _botsSubscription; + /// [Paginated]es used by this [ChatController]. final HashSet>> _fragments = HashSet(); @@ -532,6 +534,7 @@ class ChatController extends GetxController { listController.removeListener(_listControllerListener); listController.sliverController.stickyIndex.removeListener(_updateSticky); listController.dispose(); + _botsSubscription?.cancel(); edit.value?.field.focus.removeListener(_stopTypingOnUnfocus); send.field.focus.removeListener(_stopTypingOnUnfocus); @@ -931,6 +934,23 @@ class ChatController extends GetxController { } } + for (var e in chat?.bots ?? []) { + _addBotInfo(e); + } + + _botsSubscription = chat?.bots.changes.listen((e) { + switch (e.op) { + case OperationKind.added: + _addBotInfo(e.element); + break; + + case OperationKind.updated: + case OperationKind.removed: + // No-op. + break; + } + }); + // if (chat?.chat.value.isDialog ?? false) { // botEnabled.value = chat!.chat.value.members.any( // (e) => @@ -2060,6 +2080,27 @@ class ChatController extends GetxController { }); } + void _addBotInfo(RxUser e) { + if ((e.user.value.bio?.val.length ?? 0) > 'bot '.length) { + final String? about = e.user.value.bio?.val.substring('bot '.length); + + Map? decoded; + try { + decoded = jsonDecode(about!.substring('[@bot]'.length)); + } catch (_) { + // No-op. + } + + if (decoded?['text'] != null) { + final info = BotInfoElement( + decoded!['text']!, + at: PreciseDateTime.now(), + ); + elements[info.id] = info; + } + } + } + /// Ensures the [ChatView] is scrollable. Future _ensureScrollable() async { if (isClosed) { diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index ff4cff6af1c..e52a1512a5a 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -316,7 +316,10 @@ class UserController extends GetxController { /// Removes the specified [User] from a [OngoingCall] happening in the [chat]. Future removeChatCallMember(UserId userId) async { try { - await _callService.removeChatCallMember(user!.user.value.dialog, userId); + await _callService.removeChatCallMember( + user!.user.value.dialog, + userId, + ); } on RemoveChatCallMemberException catch (e) { MessagePopup.error(e); } catch (e) { diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 13c52399d84..210e7a9cf93 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -304,6 +304,10 @@ class UserView extends StatelessWidget { } } + for (var u in dialog.bots) { + members.add(u); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -350,6 +354,11 @@ class UserView extends StatelessWidget { onCall: inCall ? () => c.removeChatCallMember(member.id) : () => c.redialChatCallMember(member.id), + onKick: member.isBot + ? () async { + dialog.removeBot(member); + } + : null, ); } diff --git a/lib/ui/page/home/widget/contact_tile.dart b/lib/ui/page/home/widget/contact_tile.dart index 89c5683e308..8c937ea0139 100644 --- a/lib/ui/page/home/widget/contact_tile.dart +++ b/lib/ui/page/home/widget/contact_tile.dart @@ -192,7 +192,13 @@ class ContactTile extends StatelessWidget { ], ), ), - if (user?.isBot == true) const Text('Bot'), + // if (user?.isBot == true) const Text('Bot'), + if (user?.isBot == true) + Icon( + Icons.smart_toy, + size: 20, + color: style.colors.secondary, + ), ...trailing, ], ), diff --git a/lib/ui/page/style/page/widgets/common/dummy_chat.dart b/lib/ui/page/style/page/widgets/common/dummy_chat.dart index ade4c437399..17d660d9060 100644 --- a/lib/ui/page/style/page/widgets/common/dummy_chat.dart +++ b/lib/ui/page/style/page/widgets/common/dummy_chat.dart @@ -142,8 +142,7 @@ class DummyRxChat extends RxChat { } @override - // TODO: implement bots - RxList get bots => RxList(); + RxObsList get bots => RxObsList(); @override Future removeBot(RxUser user) { From f09e3277e11deea7983266d50d3954c0747894cd Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 30 Apr 2024 18:17:03 +0300 Subject: [PATCH 54/88] Add `actions` to `BotInfoElement` --- lib/ui/page/home/page/chat/controller.dart | 13 ++++++-- lib/ui/page/home/page/chat/view.dart | 39 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index df2064e6e38..dc8d47e2fdb 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -2095,6 +2095,10 @@ class ChatController extends GetxController { final info = BotInfoElement( decoded!['text']!, at: PreciseDateTime.now(), + actions: (decoded['actions'] as List?)?.map((e) { + return BotAction(text: e['text'], command: e['command']); + }).toList() ?? + [], ); elements[info.id] = info; } @@ -2465,11 +2469,16 @@ class LoaderElement extends ListElement { /// [ListElement] representing a [ChatInfo]. class BotInfoElement extends ListElement { - BotInfoElement(this.string, {required PreciseDateTime at}) - : super(ListElementId(at, const ChatItemId('0'))); + BotInfoElement( + this.string, { + required PreciseDateTime at, + this.actions = const [], + }) : super(ListElementId(at, const ChatItemId('0'))); /// [String] of this [BotInfoElement]. final String string; + + final List actions; } /// [ListElement] representing a [ChatInfo]. diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 80e9d5ed6c2..c865d537388 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1152,7 +1152,44 @@ class ChatView extends StatelessWidget { border: Border.all(color: color, width: 0.5), color: style.systemMessageColor, ), - child: Text(element.string, style: style.systemMessageStyle), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(element.string, style: style.systemMessageStyle), + if (element.actions.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 2, + runSpacing: 2, + children: element.actions.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () => c.postCommand(e.command), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.systemMessageColor, + width: 1, + ), + color: color, + ), + child: Text( + e.text, + style: style.fonts.smallest.regular.onPrimary, + ), + ), + ), + ); + }).toList(), + ), + ], + ], + ), ), ), ); From 08176c22549e464495b18e0d99d1387b8ac8f380 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 30 Apr 2024 18:21:14 +0300 Subject: [PATCH 55/88] Fix `Row` not being `Column` --- lib/ui/page/home/page/chat/view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index c865d537388..f993c4a8580 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1152,7 +1152,7 @@ class ChatView extends StatelessWidget { border: Border.all(color: color, width: 0.5), color: style.systemMessageColor, ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(element.string, style: style.systemMessageStyle), From 6aa9ae1b0f8b5fffc4164e775e00622c954c93a1 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 30 Apr 2024 18:32:58 +0300 Subject: [PATCH 56/88] Use `SOCAPP_LOG_LEVEL=debug` --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f74d27218..6dfdcf469ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,11 +236,12 @@ jobs: # TODO: Use `split-debug-info` for Windows once Sentry supports it: # https://github.com/getsentry/sentry-dart/issues/433 # https://github.com/getsentry/sentry-dart/issues/896 - - run: make flutter.build platform=${{ matrix.platform }} profile=yes + - run: make flutter.build platform=${{ matrix.platform }} dart-env='SOCAPP_HTTP_URL=${{ secrets.BACKEND_URL }} SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} SOCAPP_WS_PORT=${{ secrets.BACKEND_PORT }} + SOCAPP_LOG_LEVEL=debug SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} @@ -381,11 +382,12 @@ jobs: # TODO: Use `split-debug-info` when Sentry supports Linux debug symbols. # https://github.com/getsentry/sentry-dart/issues/433 - - run: make flutter.build platform=linux profile=yes + - run: make flutter.build platform=linux dart-env='SOCAPP_HTTP_URL=${{ secrets.BACKEND_URL }} SOCAPP_HTTP_PORT=${{ secrets.BACKEND_PORT }} SOCAPP_WS_URL=${{ secrets.BACKEND_WS }} SOCAPP_WS_PORT=${{ secrets.BACKEND_PORT }} + SOCAPP_LOG_LEVEL=debug SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} From 67a48720b5c9e5d59921e96c2c33380f327f9cf6 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 2 May 2024 10:28:28 +0300 Subject: [PATCH 57/88] Fix `ReactiveTextField` missing default style --- lib/ui/widget/text_field.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/widget/text_field.dart b/lib/ui/widget/text_field.dart index 71ba4b487be..868effe3617 100644 --- a/lib/ui/widget/text_field.dart +++ b/lib/ui/widget/text_field.dart @@ -325,7 +325,7 @@ class ReactiveTextField extends StatelessWidget { ? CupertinoTextSelectionControls() : null, controller: state.controller, - style: this.style, + style: this.style ?? style.fonts.medium.regular.onBackground, focusNode: state.focus, onChanged: (s) { state.isEmpty.value = s.isEmpty; From fd24dc660b898ae5071673d45137d8d0f316a4c9 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 2 May 2024 17:56:05 +0300 Subject: [PATCH 58/88] Add l10n to `BotInfo` messages --- lib/domain/model/chat_item.dart | 57 +++++++++++-- lib/store/chat_rx.dart | 18 ++++ lib/ui/page/home/page/chat/controller.dart | 82 +++++++++++-------- .../page/chat/info/add_member/controller.dart | 16 ++-- lib/ui/page/home/page/chat/view.dart | 3 +- .../page/home/page/chat/widget/chat_item.dart | 54 ++++++------ lib/ui/widget/markdown.dart | 6 +- 7 files changed, 155 insertions(+), 81 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 392159307f7..1e2cd73b89d 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -16,9 +16,11 @@ // . import 'dart:convert'; +import 'dart:ui'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:messenger/l10n/l10n.dart'; import 'package:uuid/uuid.dart'; import '../model_type_id.dart'; @@ -159,11 +161,50 @@ class ChatItemId extends NewType { bool get isLocal => val.startsWith('local.'); } +class ChatBotText { + const ChatBotText({ + this.title, + required this.text, + this.actions = const [], + }); + + final String? title; + final String text; + final List actions; + + Map toMap() { + return { + if (title != null) 'title': title, + 'text': text, + if (actions.isNotEmpty) + 'actions': + actions.map((e) => {'text': e.text, 'command': e.command}).toList(), + }; + } +} + /// Text of a [ChatMessage]. @HiveType(typeId: ModelTypeId.chatMessageText) class ChatMessageText extends NewType { const ChatMessageText(super.val); + factory ChatMessageText.bot({ + String? title, + Map localized = const {}, + ChatBotText? text, + }) { + return ChatMessageText( + '[@bot]${jsonEncode( + { + for (var e in localized.entries) ...{ + e.key.toLanguageTag(): e.value.toMap(), + }, + ...text?.toMap() ?? {}, + }, + )}', + ); + } + /// Maximum allowed number of characters in this [ChatMessageText]. static const int maxLength = 8192; @@ -280,15 +321,19 @@ class BotInfo extends ChatItem { } if (decoded != null) { + final text = + decoded[L10n.chosen.value!.toString()]?['text'] ?? decoded['text']; + final actions = decoded[L10n.chosen.value!.toString()]?['actions'] ?? + decoded['actions']; + return BotInfo( msg.id, msg.chatId, msg.author, msg.at, - text: - decoded['text'] == null ? null : ChatMessageText(decoded['text']), + text: text == null ? null : ChatMessageText(text), repliesTo: msg.repliesTo.firstOrNull, - actions: (decoded['actions'] as List?)?.map((e) { + actions: (actions as List?)?.map((e) { return BotAction(text: e['text'], command: e['command']); }).toList(), title: decoded['title'] ?? 'Bot', @@ -314,14 +359,14 @@ class BotInfo extends ChatItem { @HiveType(typeId: ModelTypeId.botAction) class BotAction { - BotAction({ + const BotAction({ required this.text, required this.command, }); @HiveField(0) - String text; + final String text; @HiveField(1) - String command; + final String command; } diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index edda7084dd6..e974733a116 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -16,6 +16,7 @@ // . import 'dart:async'; +import 'dart:ui'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; @@ -982,6 +983,23 @@ class HiveRxChat extends RxChat { @override Future addBot(RxUser user) async { bots.addIf(!bots.contains(user), user); + + await postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: + 'Translation service is enabled. Please, [connect your translation.com](https://translation.com) account.', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: + 'Переводческий сервис подключен. Пожалуйста, [привяжите Ваш translation.com](https://translation.com) аккаунт.', + ), + }, + ), + ); } @override diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index dc8d47e2fdb..712a02a09dc 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -1056,37 +1056,44 @@ class ChatController extends GetxController { if (command == '/translate') { await _chatService.sendChatMessage( id, - text: ChatMessageText( - '[@bot]${jsonEncode( - { - 'title': 'Translation', - 'text': + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): ChatBotText( + title: 'Translation', + text: 'Detected: English. Your message contains ${repliesTo.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (repliesTo.text?.val.length ?? 0)}', - 'actions': [ - { - 'text': 'Order translation', - 'command': '/proceed', - }, - { - 'text': 'Change language', - 'command': '/language', - } + actions: const [ + BotAction(text: 'Order translation', command: '/proceed'), + BotAction(text: 'Change language', command: '/language'), ], - }, - )}', + ), + const Locale('ru', 'RU'): ChatBotText( + title: 'Перевод', + text: + 'Определён: Русский. Ваше сообщение содержит ${repliesTo.text?.val.length} символов, перевод будет стоить: \$${1.1 / 100 * (repliesTo.text?.val.length ?? 0)}', + actions: const [ + BotAction(text: 'Заказать перевод', command: '/proceed'), + BotAction(text: 'Изменить язык', command: '/language'), + ], + ), + }, ), repliesTo: [repliesTo], ); } else if (command == '/proceed') { await _chatService.sendChatMessage( id, - text: ChatMessageText( - '[@bot]${jsonEncode( - { - 'title': 'Translation', - 'text': 'Translating... 💭', - }, - )}', + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: 'Translating... 💭', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: 'Переводим... 💭', + ), + }, ), repliesTo: [repliesTo], ); @@ -1095,13 +1102,17 @@ class ChatController extends GetxController { await _chatService.sendChatMessage( id, - text: ChatMessageText( - '[@bot]${jsonEncode( - { - 'title': 'Translation', - 'text': 'Translated ✅', - }, - )}', + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: 'Translated ✅', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: 'Переведено ✅', + ), + }, ), repliesTo: [repliesTo], ); @@ -2091,11 +2102,16 @@ class ChatController extends GetxController { // No-op. } - if (decoded?['text'] != null) { + final text = + decoded?[L10n.chosen.value!.toString()]?['text'] ?? decoded?['text']; + final actions = decoded?[L10n.chosen.value!.toString()]?['actions'] ?? + decoded?['actions']; + + if (text != null) { final info = BotInfoElement( - decoded!['text']!, + text, at: PreciseDateTime.now(), - actions: (decoded['actions'] as List?)?.map((e) { + actions: (actions as List?)?.map((e) { return BotAction(text: e['text'], command: e['command']); }).toList() ?? [], diff --git a/lib/ui/page/home/page/chat/info/add_member/controller.dart b/lib/ui/page/home/page/chat/info/add_member/controller.dart index faf4a837ca7..42c9570b4fe 100644 --- a/lib/ui/page/home/page/chat/info/add_member/controller.dart +++ b/lib/ui/page/home/page/chat/info/add_member/controller.dart @@ -111,16 +111,16 @@ class AddChatMemberController extends GetxController { Future addMembers(List ids) async { status.value = RxStatus.loading(); - if (chat.value?.chat.value.isGroup == false) { - if (ids.length == 1) { - final user = await _userService.get(ids.first); - if (user?.isBot == true) { - chat.value?.addBot(user!); - pop?.call(); - return; - } + if (ids.length == 1) { + final user = await _userService.get(ids.first); + if (user?.isBot == true) { + chat.value?.addBot(user!); + pop?.call(); + return; } + } + if (chat.value?.chat.value.isGroup == false) { try { chat.value = await _chatService.createGroupChat(ids); pop?.call(); diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index f993c4a8580..64c5c7be695 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1141,6 +1141,7 @@ class ChatView extends StatelessWidget { }); } else if (element is BotInfoElement) { const Color color = Color.fromARGB(255, 149, 209, 149); + // const Color color = Color(0xFFddf1f4); return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), @@ -1150,7 +1151,7 @@ class ChatView extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all(color: color, width: 0.5), - color: style.systemMessageColor, + color: const Color(0xFFddf1f4), ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 292eb8b6e4b..a548fc434d4 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -25,6 +25,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show SelectedContent; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/widget/markdown.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../../../domain/service/chat.dart'; @@ -415,7 +416,6 @@ class _ChatItemWidgetState extends State { @override void initState() { _populateWorker(); - super.initState(); } @@ -884,9 +884,10 @@ class _ChatItemWidgetState extends State { ), child: Column( children: [ - Text('${e.text}', style: style.fonts.smallest.regular.secondary), + // Text('${e.text}', style: style.fonts.smallest.regular.secondary), + if (e.text != null) MarkdownWidget(e.text!.val), + if (e.actions != null && e.text != null) const SizedBox(height: 4), if (e.actions != null) ...[ - const SizedBox(height: 4), Wrap( spacing: 2, runSpacing: 2, @@ -931,7 +932,14 @@ class _ChatItemWidgetState extends State { final ChatMessage msg = widget.item.value as ChatMessage; final BotInfo info = _bot!; - const Color color = Color.fromARGB(255, 149, 209, 149); + // const Color color = Color.fromARGB(255, 149, 209, 149); + // const Color color = Color(0xFFb68ad1); + // const Color color = Color(0xFF1f8429); + // const Color color = Color(0xFFD2E3F9); + // const Color color = Color.fromARGB(255, 228, 228, 228); + + const Color color = Color(0xFFddf1f4); + // const Color color = Color.fromARGB(255, 161, 255, 183); return _rounded( context, @@ -944,7 +952,8 @@ class _ChatItemWidgetState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all(color: color, width: 0.5), - color: style.systemMessageColor, + // color: style.systemMessageColor, + color: color, ), child: IntrinsicWidth( child: Column( @@ -958,7 +967,12 @@ class _ChatItemWidgetState extends State { ); }, ), - Text('${info.text}', style: style.systemMessageStyle), + if (info.text != null) + MarkdownWidget( + info.text!.val, + style: style.systemMessageStyle, + ), + // Text('${info.text}', style: style.systemMessageStyle), if (info.actions != null) ...[ const SizedBox(height: 4), Wrap( @@ -2029,32 +2043,10 @@ class _ChatItemWidgetState extends State { repliesTo: msg.repliesTo.firstOrNull, text: ChatMessageText(text!.val.substring(1)), ); + } else if (msg.repliesTo.isEmpty && + (text?.val.startsWith('[@bot]') ?? false)) { + _bot = BotInfo.parse(msg); } - // else if (text?.val.startsWith('[@bot]') ?? false) { - // Map? decoded; - - // try { - // decoded = jsonDecode(text!.val.substring('[@bot]'.length)); - // } catch (_) { - // // No-op. - // } - - // if (decoded != null) { - // _bot = BotInfo( - // msg.id, - // msg.chatId, - // msg.author, - // msg.at, - // text: decoded['text'] == null - // ? null - // : ChatMessageText(decoded['text']), - // repliesTo: msg.repliesTo.firstOrNull, - // actions: (decoded['actions'] as List?)?.map((e) { - // return BotAction(text: e['text'], command: e['command']); - // }).toList(), - // ); - // } - // } } _worker = ever(widget.item, (ChatItem item) { diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index 307f67dc5b6..ae5bfe4ac78 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -23,11 +23,13 @@ import '/themes.dart'; /// [MarkdownBody] stylized with the [Style]. class MarkdownWidget extends StatelessWidget { - const MarkdownWidget(this.body, {super.key}); + const MarkdownWidget(this.body, {super.key, this.style}); /// Text to parse and render as a markdown. final String body; + final TextStyle? style; + @override Widget build(BuildContext context) { final style = Theme.of(context).style; @@ -41,7 +43,7 @@ class MarkdownWidget extends StatelessWidget { // TODO: Exception. h2: style.fonts.largest.bold.onBackground.copyWith(fontSize: 20), - p: style.fonts.normal.regular.onBackground, + p: this.style ?? style.fonts.normal.regular.onBackground, code: style.fonts.small.regular.onBackground.copyWith( letterSpacing: 1.2, backgroundColor: style.colors.secondaryHighlight, From a652bf7f18f94e97b0f2ea7c94ab007d57366a2f Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 2 May 2024 18:05:29 +0300 Subject: [PATCH 59/88] Improve UI of `BotInfo`s --- lib/domain/model/chat_item.dart | 4 +- .../page/home/page/chat/widget/chat_item.dart | 139 ++++++++---------- 2 files changed, 68 insertions(+), 75 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 1e2cd73b89d..76c97ca010e 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -321,6 +321,8 @@ class BotInfo extends ChatItem { } if (decoded != null) { + final title = decoded[L10n.chosen.value!.toString()]?['title'] ?? + decoded['title']; final text = decoded[L10n.chosen.value!.toString()]?['text'] ?? decoded['text']; final actions = decoded[L10n.chosen.value!.toString()]?['actions'] ?? @@ -336,7 +338,7 @@ class BotInfo extends ChatItem { actions: (actions as List?)?.map((e) { return BotAction(text: e['text'], command: e['command']); }).toList(), - title: decoded['title'] ?? 'Bot', + title: title ?? 'Bot', ); } } diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index a548fc434d4..ada4ea2de8e 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -885,7 +885,11 @@ class _ChatItemWidgetState extends State { child: Column( children: [ // Text('${e.text}', style: style.fonts.smallest.regular.secondary), - if (e.text != null) MarkdownWidget(e.text!.val), + if (e.text != null) + MarkdownWidget( + e.text!.val, + style: style.systemMessageStyle, + ), if (e.actions != null && e.text != null) const SizedBox(height: 4), if (e.actions != null) ...[ Wrap( @@ -941,83 +945,70 @@ class _ChatItemWidgetState extends State { const Color color = Color(0xFFddf1f4); // const Color color = Color.fromARGB(255, 161, 255, 183); - return _rounded( - context, - (_, constraints) { - return Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all(color: color, width: 0.5), - // color: style.systemMessageColor, - color: color, - ), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...msg.repliesTo.map( - (e) { - return WidgetButton( - onPressed: () => widget.onRepliedTap?.call(e), - child: _repliedMessage(e, constraints), - ); - }, - ), - if (info.text != null) - MarkdownWidget( - info.text!.val, - style: style.systemMessageStyle, - ), - // Text('${info.text}', style: style.systemMessageStyle), - if (info.actions != null) ...[ - const SizedBox(height: 4), - Wrap( - spacing: 2, - runSpacing: 2, - children: info.actions!.map((e) { - return SelectionContainer.disabled( - child: WidgetButton( - onPressed: () async { - final ChatService chatService = Get.find(); - await chatService.sendChatMessage( - msg.chatId, - text: ChatMessageText(e.command), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: style.systemMessageColor, - width: 1, - ), - color: color, - ), - child: Text( - e.text, - style: style.fonts.small.regular.onPrimary, - ), + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: color, width: 0.5), + // color: style.systemMessageColor, + color: color, + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (info.text != null) + MarkdownWidget( + info.text!.val, + style: style.systemMessageStyle, + ), + // Text('${info.text}', style: style.systemMessageStyle), + if (info.actions != null) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 2, + runSpacing: 2, + children: info.actions!.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () async { + final ChatService chatService = Get.find(); + await chatService.sendChatMessage( + msg.chatId, + text: ChatMessageText(e.command), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.systemMessageColor, + width: 1, ), + color: color, ), - ); - }).toList(), - ), - ], - ], - ), - ), + child: Text( + e.text, + style: style.fonts.small.regular.onPrimary, + ), + ), + ), + ); + }).toList(), + ), + ], + ], ), ), - ); - }, + ), + ), ); } From d239383638e1dacb8232fc0d8249bb4b098c4231 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 3 May 2024 13:33:43 +0300 Subject: [PATCH 60/88] Remove `Exception` when uploading `Attachment`s --- lib/store/chat.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/store/chat.dart b/lib/store/chat.dart index 61fac46c586..484d5e77e6f 100644 --- a/lib/store/chat.dart +++ b/lib/store/chat.dart @@ -866,7 +866,6 @@ class ChatRepository extends DisposableInterface ); } - throw Exception(); var response = await _graphQlProvider.uploadAttachment( upload, onSendProgress: (now, max) => attachment.progress.value = now / max, From e40c267af0786e8ac2c950e1cb579e6fd1dcea08 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 3 May 2024 18:09:38 +0300 Subject: [PATCH 61/88] Improve bot behaviour --- lib/domain/repository/chat.dart | 2 +- lib/store/chat_rx.dart | 106 +++++++++++++++--- lib/ui/page/home/page/chat/controller.dart | 49 ++++---- .../style/page/widgets/common/dummy_chat.dart | 2 +- 4 files changed, 119 insertions(+), 40 deletions(-) diff --git a/lib/domain/repository/chat.dart b/lib/domain/repository/chat.dart index 26581d44210..f868e93151b 100644 --- a/lib/domain/repository/chat.dart +++ b/lib/domain/repository/chat.dart @@ -300,7 +300,7 @@ abstract class RxChat implements Comparable { RxBool get inCall; RxObsList get bots; - Future addBot(RxUser user); + Future addBot(RxUser user, {bool first = true}); Future removeBot(RxUser user); /// Fetches the [Paginated] page around the [item], if specified, or diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index e974733a116..703807a2fa1 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -23,8 +23,10 @@ import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:messenger/domain/service/user.dart'; import 'package:mutex/mutex.dart'; +import '../util/get.dart'; import '/api/backend/schema.dart' show ChatCallFinishReason, ChatKind, PostChatMessageErrorCode; import '/domain/model/attachment.dart'; @@ -756,6 +758,46 @@ class HiveRxChat extends RxChat { remove(message.value.id); _pending.remove(message.value); message = event.item as HiveChatMessage; + + if (text?.val.startsWith('/') == false && + text?.val.startsWith('[@bot]') == false && + text?.val.contains('[no-bot]') == false) { + if (bots.isNotEmpty) { + final msg = message.value as ChatMessage; + + postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): ChatBotText( + title: 'Translation', + text: + 'Detected: English. Your message contains ${msg.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (msg.text?.val.length ?? 0)}', + actions: const [ + BotAction( + text: 'Translate and send', + command: '/proceed', + ), + BotAction(text: 'Don\'t translate', command: ''), + ], + ), + const Locale('ru', 'RU'): ChatBotText( + title: 'Перевод', + text: + 'Определён: Русский. Ваше сообщение содержит ${msg.text?.val.length} символов, перевод будет стоить: \$${1.1 / 100 * (msg.text?.val.length ?? 0)}', + actions: const [ + BotAction( + text: 'Перевести и отправить', + command: '/proceed', + ), + BotAction(text: 'Не переводить', command: ''), + ], + ), + }, + ), + repliesTo: [msg], + ); + } + } } } catch (e) { message.value.status.value = SendingStatus.error; @@ -981,30 +1023,34 @@ class HiveRxChat extends RxChat { final RxObsList bots = RxObsList(); @override - Future addBot(RxUser user) async { + Future addBot(RxUser user, {bool first = true}) async { bots.addIf(!bots.contains(user), user); - await postChatMessage( - text: ChatMessageText.bot( - localized: { - const Locale('en', 'US'): const ChatBotText( - title: 'Translation', - text: - 'Translation service is enabled. Please, [connect your translation.com](https://translation.com) account.', - ), - const Locale('ru', 'RU'): const ChatBotText( - title: 'Перевод', - text: - 'Переводческий сервис подключен. Пожалуйста, [привяжите Ваш translation.com](https://translation.com) аккаунт.', - ), - }, - ), - ); + if (first) { + await postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: + 'Translation service is enabled. \nCertificated translators in real-time.', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: + 'Переводческий сервис подключен. \nСертифицированные переводчики в режиме реального времени.', + ), + }, + ), + ); + } } @override Future removeBot(RxUser user) async { bots.remove(user); + + await postChatMessage(text: ChatMessageText('/kick @${user.id}')); } @override @@ -1949,6 +1995,32 @@ class HiveRxChat extends RxChat { break; } } + + if (item is HiveChatMessage) { + final msg = item.value as ChatMessage; + + if (bots.isEmpty) { + if (msg.text?.val.startsWith('[@bot]') == true) { + final userService = Get.findOrNull(); + + if (userService != null) { + final search = + userService.search(login: UserLogin('translateit')); + search.around().then((_) { + final user = search.items.values.firstOrNull; + if (user?.isBot ?? false) { + addBot(user!, first: false); + } + }); + } + } + } else { + if (msg.text?.val.startsWith('/kick @') == true) { + final id = msg.text!.val.replaceFirst('/kick @', ''); + bots.removeWhere((e) => e.id.val == id); + } + } + } break; case ChatEventKind.totalItemsCountUpdated: diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 712a02a09dc..c594bba713a 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -456,26 +456,29 @@ class ChatController extends GetxController { send.replied.isNotEmpty) { _chatService .sendChatMessage( - chat?.chat.value.id ?? id, - text: send.field.text.trim().isEmpty - ? null - : ChatMessageText(send.field.text.trim()), - repliesTo: send.replied.map((e) => e.value).toList(), - attachments: send.attachments.map((e) => e.value).toList(), - ) + chat?.chat.value.id ?? id, + text: send.field.text.trim().isEmpty + ? null + : ChatMessageText(send.field.text.trim()), + repliesTo: send.replied.map((e) => e.value).toList(), + attachments: send.attachments.map((e) => e.value).toList(), + ) .then( - (_) => AudioUtils.once( - AudioSource.asset('audio/message_sent.mp3'), - ), - ) - .onError( - (_, __) => _showBlockedPopup(), - test: (e) => e.code == PostChatMessageErrorCode.blocked, - ) - .onError( - (e, _) => MessagePopup.error(e), - ) - .onError((e, _) {}); + (_) { + AudioUtils.once( + AudioSource.asset('audio/message_sent.mp3'), + ); + }, + ).onError( + (_, __) { + _showBlockedPopup(); + }, + test: (e) => e.code == PostChatMessageErrorCode.blocked, + ).onError( + (e, _) { + MessagePopup.error(e); + }, + ).onError((e, _) {}); send.clear(unfocus: false); @@ -1048,8 +1051,12 @@ class ChatController extends GetxController { Future postCommand(String command, {ChatItem? repliesTo}) async { await _chatService.sendChatMessage( id, - text: ChatMessageText(command), - repliesTo: [if (repliesTo != null) repliesTo], + text: command.isEmpty + ? repliesTo is ChatMessage + ? ChatMessageText('${repliesTo.text?.val} [no-bot]') + : null + : ChatMessageText(command), + repliesTo: command.isEmpty ? [] : [if (repliesTo != null) repliesTo], ); if (repliesTo is ChatMessage) { diff --git a/lib/ui/page/style/page/widgets/common/dummy_chat.dart b/lib/ui/page/style/page/widgets/common/dummy_chat.dart index 17d660d9060..08eed774167 100644 --- a/lib/ui/page/style/page/widgets/common/dummy_chat.dart +++ b/lib/ui/page/style/page/widgets/common/dummy_chat.dart @@ -136,7 +136,7 @@ class DummyRxChat extends RxChat { int compareTo(RxChat other) => 0; @override - Future addBot(RxUser user) { + Future addBot(RxUser user, {bool first = false}) { // TODO: implement addBot throw UnimplementedError(); } From 9cacd2bda8565d6ea71e2963476687dbe6e1f487 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 6 May 2024 17:53:14 +0300 Subject: [PATCH 62/88] Improve `BotInfo`s behaviour --- lib/domain/model/chat_item.dart | 9 ++ lib/store/chat_rx.dart | 20 ++-- lib/ui/page/home/page/chat/controller.dart | 37 ++++--- lib/ui/page/home/page/chat/view.dart | 97 ++++++++++++++++--- .../page/home/page/chat/widget/chat_item.dart | 31 +++--- .../home/tab/chats/widget/recent_chat.dart | 10 +- lib/ui/widget/markdown.dart | 1 + lib/ui/worker/chat.dart | 4 + 8 files changed, 162 insertions(+), 47 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 76c97ca010e..c6106f4670e 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -70,6 +70,15 @@ abstract class ChatItem { /// Meant to be used as a key sorted by posting [DateTime] of this [ChatItem]. ChatItemKey get key => ChatItemKey(at, id); + bool get isCommand { + if (this is! ChatMessage) { + return false; + } + + final msg = this as ChatMessage; + return msg.text?.val.startsWith('/') == true; + } + @override String toString() => '$runtimeType($id, $chatId)'; } diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index 703807a2fa1..f9247be79ab 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -297,8 +297,8 @@ class HiveRxChat extends RxChat { ChatItem? get lastItem { ChatItem? item = chat.value.lastItem; if (messages.isNotEmpty) { - final ChatItem last = messages.last.value; - if (item?.at.isBefore(last.at) == true) { + final ChatItem last = messages.lastWhere((e) => !e.value.isCommand).value; + if (item?.at.isBefore(last.at) == true || item?.isCommand == true) { item = last; } } @@ -771,25 +771,25 @@ class HiveRxChat extends RxChat { const Locale('en', 'US'): ChatBotText( title: 'Translation', text: - 'Detected: English. Your message contains ${msg.text?.val.length} symbols, which will cost in total: \$${1.1 / 100 * (msg.text?.val.length ?? 0)}', + 'English - Russian. Cost: \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', actions: const [ + BotAction(text: 'Send', command: '/resend'), BotAction( text: 'Translate and send', command: '/proceed', ), - BotAction(text: 'Don\'t translate', command: ''), ], ), const Locale('ru', 'RU'): ChatBotText( title: 'Перевод', text: - 'Определён: Русский. Ваше сообщение содержит ${msg.text?.val.length} символов, перевод будет стоить: \$${1.1 / 100 * (msg.text?.val.length ?? 0)}', + 'Русский - Английский. Стоимость: \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', actions: const [ + BotAction(text: 'Отправить', command: '/resend'), BotAction( text: 'Перевести и отправить', command: '/proceed', ), - BotAction(text: 'Не переводить', command: ''), ], ), }, @@ -1024,8 +1024,6 @@ class HiveRxChat extends RxChat { @override Future addBot(RxUser user, {bool first = true}) async { - bots.addIf(!bots.contains(user), user); - if (first) { await postChatMessage( text: ChatMessageText.bot( @@ -1033,17 +1031,19 @@ class HiveRxChat extends RxChat { const Locale('en', 'US'): const ChatBotText( title: 'Translation', text: - 'Translation service is enabled. \nCertificated translators in real-time.', + 'Translation service is enabled. Certificated translators in real-time.', ), const Locale('ru', 'RU'): const ChatBotText( title: 'Перевод', text: - 'Переводческий сервис подключен. \nСертифицированные переводчики в режиме реального времени.', + 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени.', ), }, ), ); } + + bots.addIf(!bots.contains(user), user); } @override diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index c594bba713a..af38861ec3e 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -1123,6 +1123,19 @@ class ChatController extends GetxController { ), repliesTo: [repliesTo], ); + } else if (command == '/resend') { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): + const ChatBotText(title: 'Translation', text: ''), + const Locale('ru', 'RU'): + const ChatBotText(title: 'Перевод', text: ''), + }, + ), + repliesTo: [repliesTo], + ); } } } @@ -2098,6 +2111,8 @@ class ChatController extends GetxController { }); } + final Rx botInfo = Rx(null); + void _addBotInfo(RxUser e) { if ((e.user.value.bio?.val.length ?? 0) > 'bot '.length) { final String? about = e.user.value.bio?.val.substring('bot '.length); @@ -2114,17 +2129,15 @@ class ChatController extends GetxController { final actions = decoded?[L10n.chosen.value!.toString()]?['actions'] ?? decoded?['actions']; - if (text != null) { - final info = BotInfoElement( - text, - at: PreciseDateTime.now(), - actions: (actions as List?)?.map((e) { - return BotAction(text: e['text'], command: e['command']); - }).toList() ?? - [], - ); - elements[info.id] = info; - } + botInfo.value = BotInfoElement( + text, + at: PreciseDateTime.now(), + actions: (actions as List?)?.map((e) { + return BotAction(text: e['text'], command: e['command']); + }).toList() ?? + [], + ); + // elements[botInfo.id] = botInfo; } } @@ -2499,7 +2512,7 @@ class BotInfoElement extends ListElement { }) : super(ListElementId(at, const ChatItemId('0'))); /// [String] of this [BotInfoElement]. - final String string; + final String? string; final List actions; } diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 64c5c7be695..ecd2a639dbc 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1156,9 +1156,11 @@ class ChatView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(element.string, style: style.systemMessageStyle), - if (element.actions.isNotEmpty) ...[ + if (element.string != null) + Text(element.string!, style: style.systemMessageStyle), + if (element.string != null && element.actions.isNotEmpty) const SizedBox(height: 4), + if (element.actions.isNotEmpty) ...[ Wrap( spacing: 2, runSpacing: 2, @@ -1549,15 +1551,88 @@ class ChatView extends StatelessWidget { ); } - return MessageFieldView( - key: const Key('SendField'), - controller: c.send, - onChanged: c.chat?.chat.value.isMonolog == true ? null : c.updateTyping, - onItemPressed: (item) => - c.animateTo(item.id, item: item, addToHistory: false), - canForward: true, - onAttachmentError: c.chat?.updateAttachments, - // symbols: c.hasBot, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Obx(() { + final e = c.botInfo.value; + if (e == null) { + return const SizedBox(); + } + + const Color color = Color.fromARGB(255, 149, 209, 149); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + // scrollDirection: Axis.horizontal, + children: [ + if (c.botInfo.value!.string != null) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color, width: 0.5), + color: const Color(0xFFddf1f4), + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Text( + c.botInfo.value!.string!, + style: style.fonts.small.regular.secondary, + ), + ), + if (c.botInfo.value!.string != null && e.actions.isNotEmpty) + const SizedBox(width: 8), + ...e.actions.map((e) { + return WidgetButton( + onPressed: () => c.postCommand(e.command), + child: Container( + margin: const EdgeInsets.fromLTRB(2, 0, 2, 0), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + // color: style.systemMessageColor, + color: const Color(0xFFD3E3E6), + // color: const Color(0xFFCEE1E4), + width: 1, + ), + color: const Color(0xFFddf1f4), + ), + child: Text( + e.text, + style: style.fonts.small.regular.primary, + ), + ), + ); + }), + ], + ), + ), + ); + }), + MessageFieldView( + key: const Key('SendField'), + controller: c.send, + onChanged: + c.chat?.chat.value.isMonolog == true ? null : c.updateTyping, + onItemPressed: (item) => + c.animateTo(item.id, item: item, addToHistory: false), + canForward: true, + onAttachmentError: c.chat?.updateAttachments, + // symbols: c.hasBot, + ), + ], ); }); } diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index ada4ea2de8e..79b5fb70d52 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -850,11 +850,16 @@ class _ChatItemWidgetState extends State { final ChatMessage msg = widget.item.value as ChatMessage; - const Color color = Color.fromARGB(255, 149, 209, 149); + if (e.text?.val.isEmpty == true) { + return const SizedBox(); + } + + // const Color color = Color.fromARGB(255, 149, 209, 149); final InputBorder border = OutlineInputBorder( borderSide: BorderSide( - color: style.primaryBorder.top.color, + // color: style.primaryBorder.top.color, + color: const Color(0xFFD3E3E6), width: style.primaryBorder.top.width, ), borderRadius: BorderRadius.circular(15), @@ -864,13 +869,11 @@ class _ChatItemWidgetState extends State { padding: const EdgeInsets.fromLTRB(0, 12, 0, 0), child: InputDecorator( decoration: InputDecoration( - label: Text( - e.title, - // style: style.fonts.small.regular.secondary, - ), + label: Text(e.title), floatingLabelStyle: style.fonts.small.regular.secondary, filled: true, - fillColor: Colors.white.withOpacity(0.34), + // fillColor: Colors.white.withOpacity(0.34), + fillColor: const Color(0xFFddf1f4), floatingLabelAlignment: FloatingLabelAlignment.center, focusedBorder: border, errorBorder: border, @@ -888,7 +891,8 @@ class _ChatItemWidgetState extends State { if (e.text != null) MarkdownWidget( e.text!.val, - style: style.systemMessageStyle, + // style: style.systemMessageStyle, + style: style.fonts.smallest.regular.secondary, ), if (e.actions != null && e.text != null) const SizedBox(height: 4), if (e.actions != null) ...[ @@ -907,14 +911,17 @@ class _ChatItemWidgetState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all( - color: style.systemMessageColor, + color: const Color(0xFFD3E3E6), width: 1, ), - color: color, + color: const Color(0xFFddf1f4), ), child: Text( e.text, - style: style.fonts.smallest.regular.onPrimary, + style: + style.fonts.smallest.regular.secondary.copyWith( + color: style.colors.primary, + ), ), ), ), @@ -952,7 +959,7 @@ class _ChatItemWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), - border: Border.all(color: color, width: 0.5), + border: Border.all(color: const Color(0xFFD3E3E6), width: 0.5), // color: style.systemMessageColor, color: color, ), diff --git a/lib/ui/page/home/tab/chats/widget/recent_chat.dart b/lib/ui/page/home/tab/chats/widget/recent_chat.dart index efdfc6f1959..990d0bcb30d 100644 --- a/lib/ui/page/home/tab/chats/widget/recent_chat.dart +++ b/lib/ui/page/home/tab/chats/widget/recent_chat.dart @@ -507,8 +507,14 @@ class RecentChatTile extends StatelessWidget { final FutureOr userOrFuture = getUser?.call(item.author.id); + BotInfo? bot; if (item.text != null) { - desc.write(item.text!.val); + bot = BotInfo.parse(item); + if (bot == null) { + desc.write(item.text!.val); + } else { + desc.write(bot.text?.val ?? 'Bot replied'); + } } final List images = []; @@ -542,7 +548,7 @@ class RecentChatTile extends StatelessWidget { } subtitle = [ - if (item.author.id == me) + if (bot == null && item.author.id == me) Text('${'label_you'.l10n}${'colon_space'.l10n}') else if (chat.isGroup) Padding( diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index ae5bfe4ac78..7ae7c152ede 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -55,6 +55,7 @@ class MarkdownWidget extends StatelessWidget { blockquoteDecoration: BoxDecoration( color: style.colors.secondaryHighlight, ), + a: TextStyle(color: style.colors.primary), ), ); } diff --git a/lib/ui/worker/chat.dart b/lib/ui/worker/chat.dart index 37cf847c112..8278b44c661 100644 --- a/lib/ui/worker/chat.dart +++ b/lib/ui/worker/chat.dart @@ -360,6 +360,10 @@ class _ChatWatchData { ? 'file' : 'attachments'; + if (text?.val.startsWith('[@bot]') == true) { + return null; + } + return 'fcm_message'.l10nfmt({ 'type': type, 'text': text?.val ?? '', From fd68494fc350671f431c0cf10d884da13cf0e7a1 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 7 May 2024 17:50:27 +0300 Subject: [PATCH 63/88] Add `bot_texture` and remove text from `BotInfo`s posted --- assets/images/bot_texture.svg | 1 + lib/domain/model/chat_item.dart | 6 +- lib/store/chat_rx.dart | 18 +- lib/ui/page/home/page/chat/view.dart | 69 +++- .../page/home/page/chat/widget/chat_item.dart | 349 +++++++++++------- 5 files changed, 274 insertions(+), 169 deletions(-) create mode 100644 assets/images/bot_texture.svg diff --git a/assets/images/bot_texture.svg b/assets/images/bot_texture.svg new file mode 100644 index 00000000000..17bb07f5658 --- /dev/null +++ b/assets/images/bot_texture.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index c6106f4670e..a5f2397ff69 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -173,18 +173,18 @@ class ChatItemId extends NewType { class ChatBotText { const ChatBotText({ this.title, - required this.text, + this.text, this.actions = const [], }); final String? title; - final String text; + final String? text; final List actions; Map toMap() { return { if (title != null) 'title': title, - 'text': text, + if (text != null) 'text': text, if (actions.isNotEmpty) 'actions': actions.map((e) => {'text': e.text, 'command': e.command}).toList(), diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index f9247be79ab..d2ca391366f 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -770,24 +770,22 @@ class HiveRxChat extends RxChat { localized: { const Locale('en', 'US'): ChatBotText( title: 'Translation', - text: - 'English - Russian. Cost: \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', - actions: const [ - BotAction(text: 'Send', command: '/resend'), + actions: [ + const BotAction(text: 'Send', command: '/resend'), BotAction( - text: 'Translate and send', + text: + 'Translate and send for \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', command: '/proceed', ), ], ), const Locale('ru', 'RU'): ChatBotText( title: 'Перевод', - text: - 'Русский - Английский. Стоимость: \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', - actions: const [ - BotAction(text: 'Отправить', command: '/resend'), + actions: [ + const BotAction(text: 'Отправить', command: '/resend'), BotAction( - text: 'Перевести и отправить', + text: + 'Перевести и отправить за \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', command: '/proceed', ), ], diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index ecd2a639dbc..62c9b91f1f3 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1577,7 +1577,13 @@ class ChatView extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: color, width: 0.5), - color: const Color(0xFFddf1f4), + // color: const Color(0xFFddf1f4), + gradient: const LinearGradient( + stops: [0, 1], + colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), ), padding: const EdgeInsets.symmetric( horizontal: 6, @@ -1593,26 +1599,49 @@ class ChatView extends StatelessWidget { ...e.actions.map((e) { return WidgetButton( onPressed: () => c.postCommand(e.command), - child: Container( - margin: const EdgeInsets.fromLTRB(2, 0, 2, 0), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - // color: style.systemMessageColor, - color: const Color(0xFFD3E3E6), - // color: const Color(0xFFCEE1E4), - width: 1, + child: Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + // color: style.systemMessageColor, + color: const Color(0xFFD3E3E6), + // color: const Color(0xFFCEE1E4), + width: 1, + ), + color: const Color(0xFFddf1f4), + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [ + // Color(0xFFE1F0F3), + // Color(0xFFBDF4FF) + // ], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + child: Text( + e.text, + style: style.fonts.small.regular.primary, + ), ), - color: const Color(0xFFddf1f4), - ), - child: Text( - e.text, - style: style.fonts.small.regular.primary, - ), + ], ), ); }), diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 79b5fb70d52..ff1bf2360c9 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -865,73 +865,123 @@ class _ChatItemWidgetState extends State { borderRadius: BorderRadius.circular(15), ); - return Padding( - padding: const EdgeInsets.fromLTRB(0, 12, 0, 0), - child: InputDecorator( - decoration: InputDecoration( - label: Text(e.title), - floatingLabelStyle: style.fonts.small.regular.secondary, - filled: true, - // fillColor: Colors.white.withOpacity(0.34), - fillColor: const Color(0xFFddf1f4), - floatingLabelAlignment: FloatingLabelAlignment.center, - focusedBorder: border, - errorBorder: border, - enabledBorder: border, - disabledBorder: border, - focusedErrorBorder: border, - contentPadding: const EdgeInsets.fromLTRB(8, 16, 8, 8), - isCollapsed: true, - // contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - border: border, - ), - child: Column( - children: [ - // Text('${e.text}', style: style.fonts.smallest.regular.secondary), - if (e.text != null) - MarkdownWidget( - e.text!.val, - // style: style.systemMessageStyle, - style: style.fonts.smallest.regular.secondary, - ), - if (e.actions != null && e.text != null) const SizedBox(height: 4), - if (e.actions != null) ...[ - Wrap( - spacing: 2, - runSpacing: 2, - children: e.actions!.map((e) { - return SelectionContainer.disabled( - child: WidgetButton( - onPressed: () => widget.onAction?.call(e), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: const Color(0xFFD3E3E6), - width: 1, - ), - color: const Color(0xFFddf1f4), - ), - child: Text( - e.text, - style: - style.fonts.smallest.regular.secondary.copyWith( - color: style.colors.primary, - ), + return Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + // color: const Color(0xFFddf1f4), + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + // decoration: InputDecoration( + // // label: Text(e.title), + // floatingLabelStyle: style.fonts.small.regular.secondary, + // filled: true, + // // fillColor: Colors.white.withOpacity(0.34), + // // fillColor: const Color(0xFFddf1f4), + // floatingLabelAlignment: FloatingLabelAlignment.center, + // focusedBorder: border, + // errorBorder: border, + // enabledBorder: border, + // disabledBorder: border, + // focusedErrorBorder: border, + // contentPadding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + // isCollapsed: true, + // // contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + // border: border, + // ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Text('${e.text}', style: style.fonts.smallest.regular.secondary), + if (e.text != null) + MarkdownWidget( + e.text!.val, + // style: style.systemMessageStyle, + style: style.fonts.smaller.regular.secondary, + ), + if (e.actions != null && e.text != null) + const SizedBox(height: 2), + if (e.actions != null) ...[ + Row( + mainAxisSize: MainAxisSize.min, + // spacing: 2, + // runSpacing: 2, + // crossAxisAlignment: WrapCrossAlignment.end, + // alignment: WrapAlignment.end, + children: e.actions!.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () => widget.onAction?.call(e), + child: Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: const Color(0xFFD3E3E6), + width: 1, + ), + color: const Color(0xFFddf1f4), + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + child: Text( + e.text, + style: style.fonts.small.regular.secondary + .copyWith( + color: style.colors.primary, + ), + ), + ), + ], ), ), - ), - ); - }).toList(), - ), + ); + }).toList(), + ), + ], ], - ], + ), ), - ), + ], ); } @@ -955,65 +1005,86 @@ class _ChatItemWidgetState extends State { return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all(color: const Color(0xFFD3E3E6), width: 0.5), - // color: style.systemMessageColor, - color: color, - ), - child: IntrinsicWidth( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (info.text != null) - MarkdownWidget( - info.text!.val, - style: style.systemMessageStyle, - ), - // Text('${info.text}', style: style.systemMessageStyle), - if (info.actions != null) ...[ - const SizedBox(height: 4), - Wrap( - spacing: 2, - runSpacing: 2, - children: info.actions!.map((e) { - return SelectionContainer.disabled( - child: WidgetButton( - onPressed: () async { - final ChatService chatService = Get.find(); - await chatService.sendChatMessage( - msg.chatId, - text: ChatMessageText(e.command), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: style.systemMessageColor, - width: 1, + child: Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all(color: const Color(0xFFD3E3E6), width: 0.5), + // color: style.systemMessageColor, + color: color, + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + child: IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (info.text != null) + MarkdownWidget( + info.text!.val, + style: style.systemMessageStyle, + ), + // Text('${info.text}', style: style.systemMessageStyle), + if (info.actions != null) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 2, + runSpacing: 2, + children: info.actions!.map((e) { + return SelectionContainer.disabled( + child: WidgetButton( + onPressed: () async { + final ChatService chatService = Get.find(); + await chatService.sendChatMessage( + msg.chatId, + text: ChatMessageText(e.command), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: style.systemMessageColor, + width: 1, + ), + color: color, + ), + child: Text( + e.text, + style: style.fonts.small.regular.onPrimary, + ), ), - color: color, ), - child: Text( - e.text, - style: style.fonts.small.regular.onPrimary, - ), - ), - ), - ); - }).toList(), - ), - ], - ], + ); + }).toList(), + ), + ], + ], + ), + ), ), - ), + ], ), ), ); @@ -1048,6 +1119,10 @@ class _ChatItemWidgetState extends State { // the [ChatMessage] (e.g. if there's an [ImageAttachment]). final bool timeInBubble = media.isNotEmpty && files.isEmpty && _text == null; + final bool hasBot = widget.infos + .where((e) => + e.text?.val.isNotEmpty == true || e.actions?.isNotEmpty == true) + .isNotEmpty; return _rounded( context, @@ -1188,7 +1263,7 @@ class _ChatItemWidgetState extends State { if (files.last != e) const SizedBox(height: 6), ], ), - if (_text == null) + if (_text == null && !hasBot) Opacity(opacity: 0, child: _timestamp(msg)), ], ), @@ -1209,7 +1284,7 @@ class _ChatItemWidgetState extends State { TextSpan( children: [ if (_text != null) _text!, - if (!timeInBubble) ...[ + if (!hasBot && !timeInBubble) ...[ const WidgetSpan(child: SizedBox(width: 4)), WidgetSpan( child: @@ -1255,6 +1330,7 @@ class _ChatItemWidgetState extends State { : style.primaryBorder, ), child: Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Stack( children: [ @@ -1262,21 +1338,22 @@ class _ChatItemWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), - Positioned( - right: timeInBubble ? 6 : 8, - bottom: 4, - child: timeInBubble - ? Container( - padding: - const EdgeInsets.only(left: 4, right: 4), - decoration: BoxDecoration( - color: style.colors.onBackgroundOpacity50, - borderRadius: BorderRadius.circular(20), - ), - child: _timestamp(msg, true), - ) - : _timestamp(msg), - ) + if (!hasBot) + Positioned( + right: timeInBubble ? 6 : 8, + bottom: 4, + child: timeInBubble + ? Container( + padding: + const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + color: style.colors.onBackgroundOpacity50, + borderRadius: BorderRadius.circular(20), + ), + child: _timestamp(msg, true), + ) + : _timestamp(msg), + ) ], ), ...widget.infos.map((e) => _botInfo(context, e)), From f4352218c7146a5cf0badacce70cc78da8ce5e69 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 7 May 2024 18:02:33 +0300 Subject: [PATCH 64/88] Fix paddings of `BotInfo`s --- .../page/home/page/chat/widget/chat_item.dart | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index ff1bf2360c9..43328896d67 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -890,7 +890,7 @@ class _ChatItemWidgetState extends State { // end: Alignment.bottomCenter, // ), ), - padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + padding: const EdgeInsets.fromLTRB(9, 2, 9, 10), // decoration: InputDecoration( // // label: Text(e.title), // floatingLabelStyle: style.fonts.small.regular.secondary, @@ -931,47 +931,50 @@ class _ChatItemWidgetState extends State { return SelectionContainer.disabled( child: WidgetButton( onPressed: () => widget.onAction?.call(e), - child: Stack( - children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: const Color(0xFFD3E3E6), - width: 1, + child: Padding( + padding: const EdgeInsets.fromLTRB(1, 0, 1, 0), + child: Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, ), - color: const Color(0xFFddf1f4), - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), - ), - child: Text( - e.text, - style: style.fonts.small.regular.secondary - .copyWith( - color: style.colors.primary, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: const Color(0xFFD3E3E6), + width: 1, + ), + color: const Color(0xFFddf1f4), + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + child: Text( + e.text, + style: style.fonts.small.regular.secondary + .copyWith( + color: style.colors.primary, + ), ), ), - ), - ], + ], + ), ), ), ); From 9761a13789e1fa908e3bfbfca656ad3012e59bfa Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 8 May 2024 10:06:40 +0300 Subject: [PATCH 65/88] Improve `BorderRadius` of `BotInfo`s --- lib/ui/page/home/page/chat/view.dart | 2 +- lib/ui/page/home/page/chat/widget/chat_item.dart | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 62c9b91f1f3..f8fb29fb5cf 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1618,7 +1618,7 @@ class ChatView extends StatelessWidget { vertical: 6, ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), + borderRadius: BorderRadius.circular(6), border: Border.all( // color: style.systemMessageColor, color: const Color(0xFFD3E3E6), diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 43328896d67..0fb83724055 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -952,7 +952,7 @@ class _ChatItemWidgetState extends State { vertical: 4, ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), + borderRadius: BorderRadius.circular(6), border: Border.all( color: const Color(0xFFD3E3E6), width: 1, @@ -1123,8 +1123,10 @@ class _ChatItemWidgetState extends State { final bool timeInBubble = media.isNotEmpty && files.isEmpty && _text == null; final bool hasBot = widget.infos - .where((e) => - e.text?.val.isNotEmpty == true || e.actions?.isNotEmpty == true) + .where( + (e) => + e.text?.val.isNotEmpty == true || e.actions?.isNotEmpty == true, + ) .isNotEmpty; return _rounded( From 94f300d190f1b2b516b3f1960db02319bfffc0a7 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 8 May 2024 17:20:10 +0300 Subject: [PATCH 66/88] Add paddings between `BotAction`s --- lib/domain/model/chat_item.dart | 3 - lib/domain/model_type_id.dart | 5 - lib/provider/hive/chat_item.dart | 20 ---- lib/store/chat_rx.dart | 4 +- lib/ui/page/home/page/chat/view.dart | 2 +- .../page/home/page/chat/widget/chat_item.dart | 102 ++++++++++-------- 6 files changed, 61 insertions(+), 75 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index a5f2397ff69..a773503ef9b 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -285,7 +285,6 @@ class ChatItemKey implements Comparable { } /// Command in a [Chat]. -@HiveType(typeId: ModelTypeId.chatCommand) class ChatCommand extends ChatItem { ChatCommand( super.id, @@ -305,7 +304,6 @@ class ChatCommand extends ChatItem { } /// Command in a [Chat]. -@HiveType(typeId: ModelTypeId.botInfo) class BotInfo extends ChatItem { BotInfo( super.id, @@ -368,7 +366,6 @@ class BotInfo extends ChatItem { String title; } -@HiveType(typeId: ModelTypeId.botAction) class BotAction { const BotAction({ required this.text, diff --git a/lib/domain/model_type_id.dart b/lib/domain/model_type_id.dart index 8521bd8b340..cb2ce1157a5 100644 --- a/lib/domain/model_type_id.dart +++ b/lib/domain/model_type_id.dart @@ -129,9 +129,4 @@ class ModelTypeId { static const refreshTokenSecret = 106; static const accessToken = 107; static const accessTokenSecret = 108; - static const chatCommand = 109; - static const botInfo = 110; - static const botAction = 111; - static const hiveChatCommand = 112; - static const hiveBotInfo = 113; } diff --git a/lib/provider/hive/chat_item.dart b/lib/provider/hive/chat_item.dart index b1420cc7d69..372349a4106 100644 --- a/lib/provider/hive/chat_item.dart +++ b/lib/provider/hive/chat_item.dart @@ -240,23 +240,3 @@ class HiveChatItemQuote { @HiveField(1) ChatItemsCursor? cursor; } - -/// Persisted in [Hive] storage [ChatInfo]'s [value]. -@HiveType(typeId: ModelTypeId.hiveChatCommand) -class HiveChatCommand extends HiveChatItem { - HiveChatCommand( - super.value, - super.cursor, - super.ver, - ); -} - -/// Persisted in [Hive] storage [ChatInfo]'s [value]. -@HiveType(typeId: ModelTypeId.hiveBotInfo) -class HiveBotInfo extends HiveChatItem { - HiveBotInfo( - super.value, - super.cursor, - super.ver, - ); -} diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index d2ca391366f..e62b2340fa6 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -1029,12 +1029,12 @@ class HiveRxChat extends RxChat { const Locale('en', 'US'): const ChatBotText( title: 'Translation', text: - 'Translation service is enabled. Certificated translators in real-time.', + 'Translation service is enabled. Certificated translators in real-time. [Terms and services](https://google.com)', ), const Locale('ru', 'RU'): const ChatBotText( title: 'Перевод', text: - 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени.', + 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени. [Условия использования](https://google.com)', ), }, ), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index f8fb29fb5cf..d0ef05a3c08 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1644,7 +1644,7 @@ class ChatView extends StatelessWidget { ], ), ); - }), + }).between((a, b) => const SizedBox(width: 8)), ], ), ), diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 0fb83724055..6685a211786 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -16,7 +16,6 @@ // . import 'dart:async'; -import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; @@ -29,7 +28,6 @@ import 'package:messenger/ui/widget/markdown.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../../../domain/service/chat.dart'; -import '../../../../../widget/outlined_rounded_button.dart'; import '../controller.dart' show ChatCallFinishReasonL10n, ChatController; import '/api/backend/schema.dart' show ChatCallFinishReason; import '/config.dart'; @@ -890,7 +888,7 @@ class _ChatItemWidgetState extends State { // end: Alignment.bottomCenter, // ), ), - padding: const EdgeInsets.fromLTRB(9, 2, 9, 10), + padding: const EdgeInsets.fromLTRB(10, 2, 10, 10), // decoration: InputDecoration( // // label: Text(e.title), // floatingLabelStyle: style.fonts.small.regular.secondary, @@ -931,53 +929,52 @@ class _ChatItemWidgetState extends State { return SelectionContainer.disabled( child: WidgetButton( onPressed: () => widget.onAction?.call(e), - child: Padding( - padding: const EdgeInsets.fromLTRB(1, 0, 1, 0), - child: Stack( - children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: const Color(0xFFD3E3E6), - width: 1, - ), - color: const Color(0xFFddf1f4), - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), + child: Stack( + children: [ + // Positioned.fill( + // child: ClipRRect( + // borderRadius: BorderRadius.circular(15), + // child: const SvgImage.asset( + // 'assets/images/bot_texture.svg', + // width: double.infinity, + // height: double.infinity, + // fit: BoxFit.cover, + // ), + // ), + // ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: const Color(0xFFD3E3E6), + width: 1, ), - child: Text( - e.text, - style: style.fonts.small.regular.secondary - .copyWith( - color: style.colors.primary, - ), + color: const Color(0xFFddf1f4), + // gradient: const LinearGradient( + // stops: [0, 1], + // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + // begin: Alignment.topCenter, + // end: Alignment.bottomCenter, + // ), + ), + child: Text( + e.text, + style: style.fonts.small.regular.secondary + .copyWith( + color: style.colors.primary, ), ), - ], - ), + ), + ], ), ), ); + }).between((_, __) { + return const SizedBox(width: 8); }).toList(), ), ], @@ -2427,3 +2424,20 @@ extension LinkParsingExtension on String { return TextSpan(children: spans); } } + +extension WidgetBetweenListExtension on Iterable { + Iterable between(Widget Function(Widget a, Widget b) callback) { + final List list = []; + + if (length > 0) { + list.add(first); + } + + for (var i = 1; i < length; ++i) { + list.add(callback(elementAt(i - 1), elementAt(i))); + list.add(elementAt(i)); + } + + return list; + } +} From 9bd0d902eb507c803971821640a08c635f3cc8d9 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 8 May 2024 17:36:55 +0300 Subject: [PATCH 67/88] Fix DSN on CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dfdcf469ca..46495656ccc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,7 @@ jobs: - run: make flutter.build platform=${{ matrix.platform }} profile=yes dart-env='SOCAPP_FCM_VAPID_KEY=${{ secrets.FCM_VAPID_KEY }} SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }}' + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }}' if: ${{ matrix.platform == 'web' }} - name: Prepare Android signing resources @@ -244,7 +244,7 @@ jobs: SOCAPP_LOG_LEVEL=debug SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }} SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' if: ${{ matrix.platform != 'web' }} @@ -390,7 +390,7 @@ jobs: SOCAPP_LOG_LEVEL=debug SOCAPP_APPCAST_URL=${{ startsWith(github.ref, 'refs/tags/v') && secrets.APPCAST_STABLE || secrets.APPCAST_EDGE }} SOCAPP_LINK_PREFIX=${{ startsWith(github.ref, 'refs/tags/v') && secrets.LINK_STABLE || secrets.LINK_EDGE }} - SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_EDGE }} + SOCAPP_SENTRY_DSN=${{ startsWith(github.ref, 'refs/tags/v') && secrets.SENTRY_DSN_STABLE || secrets.SENTRY_DSN_MAIN }} SOCAPP_USER_AGENT_VERSION=${{ steps.semver.outputs.group1 }}' - name: Parse application name from Git repository name From 4fe840a9ada7dbe366a026c1e129565e5e157d13 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 8 May 2024 18:21:29 +0300 Subject: [PATCH 68/88] Merge with latest `main` --- CHANGELOG.md | 1 - assets/images/bot_texture.svg | 1 - assets/l10n/en-US.ftl | 6 - assets/l10n/ru-RU.ftl | 6 - lib/api/backend/extension/chat.dart | 15 -- .../query/user/CheckUserLoginOccupied.graphql | 3 - lib/domain/repository/auth.dart | 2 +- lib/domain/service/auth.dart | 3 +- lib/main.dart | 41 +++-- lib/provider/gql/components/user.dart | 17 -- lib/store/auth.dart | 6 - .../page/home/page/chat/info/controller.dart | 52 ------ lib/ui/page/home/page/chat/info/view.dart | 1 + .../page/chat/message_field/controller.dart | 6 - .../home/page/chat/message_field/view.dart | 105 ++++-------- lib/ui/page/home/page/chat/view.dart | 50 +----- .../page/home/page/chat/widget/chat_item.dart | 155 +++--------------- lib/ui/page/home/page/user/controller.dart | 66 +------- lib/ui/page/home/widget/avatar.dart | 4 - lib/ui/page/home/widget/contact_tile.dart | 1 - 20 files changed, 84 insertions(+), 457 deletions(-) delete mode 100644 assets/images/bot_texture.svg delete mode 100644 lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql diff --git a/CHANGELOG.md b/CHANGELOG.md index f5308a6ffd2..22f98a4736f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1171,4 +1171,3 @@ All user visible changes to this project will be documented in this file. This p [Helm]: https://helm.sh [PWA]: https://en.wikipedia.org/wiki/Progressive_web_app [Semantic Versioning 2.0.0]: https://semver.org -[Sentry]: https://sentry.io diff --git a/assets/images/bot_texture.svg b/assets/images/bot_texture.svg deleted file mode 100644 index 17bb07f5658..00000000000 --- a/assets/images/bot_texture.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index f7a467ed552..43fd6bcc80e 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -568,11 +568,6 @@ label_ago_date = {$years -> *[other] {$years} years ago } label_all = All -label_anonymous_reports = Anonymous reports -label_anonymous_reports_description = - Application may collect anonymously technical performance data and detailed reports of errors happening, if any. - - Allow application to collect and send such data? label_app_background = Application background label_application = Application label_are_you_sure_no = No @@ -587,7 +582,6 @@ label_audio_call = Audio call{$by -> *[other] {" "}by {$by} } label_audio_notifications = Audio notifications -label_avatar = Avatar label_avatar_removed = {$author} removed avatar label_avatar_removed1 = {$author} label_avatar_removed2 = {" "}removed avatar diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 6bc80400af6..07fb7e91f8f 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -591,11 +591,6 @@ label_ago_date = {$years -> *[other] {$years} лет назад } label_all = Все -label_anonymous_reports = Анонимные отчёты -label_anonymous_reports_description = - Приложение может собирать анонимно технические данные работы приложения и подробные отчёты об ошибках в случае их возникновения. - - Разрешить приложению собирать и отправлять эти данные? label_app_background = Фон приложения label_application = Приложение label_are_you_sure_no = Нет @@ -611,7 +606,6 @@ label_audio_call = Аудиозвонок{$by -> *[other] {" "}от {$by} } label_audio_notifications = Звуковые уведомления -label_avatar = Аватар label_avatar_removed = {$author} удалил аватар label_avatar_removed1 = {$author} label_avatar_removed2 = {" "}удалил аватар diff --git a/lib/api/backend/extension/chat.dart b/lib/api/backend/extension/chat.dart index ec58d2b6ef3..6d830a516dc 100644 --- a/lib/api/backend/extension/chat.dart +++ b/lib/api/backend/extension/chat.dart @@ -154,21 +154,6 @@ extension ChatMessageConversion on ChatMessageMixin { HiveChatItem toHive(ChatItemsCursor cursor) { List items = repliesTo.map((e) => e.toHive()).toList(); - // if (text?.val.startsWith('/') ?? false) { - // return HiveChatCommand( - // ChatCommand( - // id, - // chatId, - // author.toModel(), - // at, - // repliesTo: items.firstOrNull?.value, - // text: text, - // ), - // cursor, - // ver, - // ); - // } - return HiveChatMessage( ChatMessage( id, diff --git a/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql b/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql deleted file mode 100644 index 33bd744dfd5..00000000000 --- a/lib/api/backend/graphql/query/user/CheckUserLoginOccupied.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query CheckUserLoginOccupied($login: UserLogin!) { - checkUserLoginOccupied(login: $login) -} diff --git a/lib/domain/repository/auth.dart b/lib/domain/repository/auth.dart index 88d119ba04c..7ba750d6b22 100644 --- a/lib/domain/repository/auth.dart +++ b/lib/domain/repository/auth.dart @@ -137,5 +137,5 @@ abstract class AbstractAuthRepository { /// a new [Chat]-dialog or joining an existing [Chat]-group. Future useChatDirectLink(ChatDirectLinkSlug slug); - Future checkUserLoginOccupied(UserLogin login); + } diff --git a/lib/domain/service/auth.dart b/lib/domain/service/auth.dart index 93877ee66b8..be652718ef2 100644 --- a/lib/domain/service/auth.dart +++ b/lib/domain/service/auth.dart @@ -605,8 +605,7 @@ class AuthService extends GetxService { return await _authRepository.useChatDirectLink(slug); } - Future checkUserLoginOccupied(UserLogin login) => - _authRepository.checkUserLoginOccupied(login); + /// Sets authorized [status] to `isLoadingMore` (aka "partly authorized"). void _authorized(Credentials creds) { diff --git a/lib/main.dart b/lib/main.dart index 0d5c1352c33..21b7db84197 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -90,34 +90,32 @@ Future main() async { dateStamp: !PlatformUtils.isWeb, ); - await _initHive(); - - if (PlatformUtils.isDesktop && !PlatformUtils.isWeb) { - await windowManager.ensureInitialized(); - await windowManager.setMinimumSize(const Size(400, 400)); + // Initializes and runs the [App]. + Future appRunner() async { + MediaKit.ensureInitialized(); + WebUtils.setPathUrlStrategy(); - final WindowPreferencesHiveProvider preferences = Get.find(); - final WindowPreferences? prefs = preferences.get(); + await _initHive(); - if (prefs?.size != null) { - await windowManager.setSize(prefs!.size!); - } + if (PlatformUtils.isDesktop && !PlatformUtils.isWeb) { + await windowManager.ensureInitialized(); + await windowManager.setMinimumSize(const Size(400, 400)); - if (prefs?.position != null) { - await windowManager.setPosition(prefs!.position!); - } + final WindowPreferencesHiveProvider preferences = Get.find(); + final WindowPreferences? prefs = preferences.get(); - await windowManager.show(); + if (prefs?.size != null) { + await windowManager.setSize(prefs!.size!); + } - Get.put(WindowWorker(preferences)); - } + if (prefs?.position != null) { + await windowManager.setPosition(prefs!.position!); + } - await L10n.init(); + await windowManager.show(); - // Initializes and runs the [App]. - Future appRunner() async { - MediaKit.ensureInitialized(); - WebUtils.setPathUrlStrategy(); + Get.put(WindowWorker(preferences)); + } final graphQlProvider = Get.put(GraphQlProvider()); @@ -132,6 +130,7 @@ Future main() async { router = RouterState(authService); authService.init(); + await L10n.init(); Get.put(CacheWorker(Get.findOrNull(), Get.findOrNull())); Get.put(UpgradeWorker(Get.findOrNull())); diff --git a/lib/provider/gql/components/user.dart b/lib/provider/gql/components/user.dart index e748300e3ec..96809cdcf36 100644 --- a/lib/provider/gql/components/user.dart +++ b/lib/provider/gql/components/user.dart @@ -57,23 +57,6 @@ mixin UserGraphQlMixin { return GetMyUser$Query.fromJson(res.data!); } - Future checkUserLoginOccupied( - UserLogin login - ) async { - Log.debug('checkUserLoginOccupied($login)', '$runtimeType'); - - final variables = CheckUserLoginOccupiedArguments(login: login); - QueryResult res = await client.query( - QueryOptions( - operationName: 'CheckUserLoginOccupied', - document: CheckUserLoginOccupiedQuery(variables: variables).document, - variables: variables.toJson(), - ), - ); - return CheckUserLoginOccupied$Query.fromJson(res.data!) - .checkUserLoginOccupied; - } - /// Returns an [User] by its [id]. /// /// ### Authentication diff --git a/lib/store/auth.dart b/lib/store/auth.dart index 8c2b2dcc1a2..8bb80f3a26c 100644 --- a/lib/store/auth.dart +++ b/lib/store/auth.dart @@ -267,10 +267,4 @@ class AuthRepository implements AbstractAuthRepository { var response = await _graphQlProvider.useChatDirectLink(slug); return response.chat.id; } - - @override - Future checkUserLoginOccupied(UserLogin login) async { - Log.debug('checkUserLoginOccupied($login)', '$runtimeType'); - return await _graphQlProvider.checkUserLoginOccupied(login); - } } diff --git a/lib/ui/page/home/page/chat/info/controller.dart b/lib/ui/page/home/page/chat/info/controller.dart index b00e3fc693f..9ce290cec1a 100644 --- a/lib/ui/page/home/page/chat/info/controller.dart +++ b/lib/ui/page/home/page/chat/info/controller.dart @@ -22,7 +22,6 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; -import 'package:messenger/domain/model/mute_duration.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -99,16 +98,6 @@ class ChatInfoController extends GetxController { /// enabled. final RxBool nameEditing = RxBool(false); - /// Index of the [Block] that should be highlighted. - final RxnInt highlighted = RxnInt(); - - /// [ItemScrollController] of the profile's [ScrollablePositionedList]. - final ItemScrollController itemScrollController = ItemScrollController(); - - /// [ItemPositionsListener] of the profile's [ScrollablePositionedList]. - final ItemPositionsListener positionsListener = - ItemPositionsListener.create(); - /// [Chat.name] field state. late final TextFieldState name; @@ -140,13 +129,6 @@ class ChatInfoController extends GetxController { /// Settings repository, used to retrieve the [background]. final AbstractSettingsRepository _settingsRepo; - /// [Timer] resetting the [highlight] value after the [_highlightTimeout] has - /// passed. - Timer? _highlightTimer; - - /// [Duration] of the [highlight]ing. - static const Duration _highlightTimeout = Duration(seconds: 1); - /// Worker to react on [chat] changes. Worker? _worker; @@ -297,40 +279,6 @@ class ChatInfoController extends GetxController { } } - /// Renames the [Chat] to the [name]. - Future submitName() async { - ChatName? name; - - try { - name = this.name.text.isEmpty ? null : ChatName(this.name.text); - } on FormatException catch (_) { - this.name.status.value = RxStatus.empty(); - this.name.error.value = 'err_incorrect_input'.l10n; - this.name.unsubmit(); - return; - } - - if (this.name.error.value == null) { - this.name.status.value = RxStatus.loading(); - this.name.editable.value = false; - - try { - await _chatService.renameChat(chat!.chat.value.id, name); - this.name.status.value = RxStatus.empty(); - this.name.unsubmit(); - } on RenameChatException catch (e) { - this.name.status.value = RxStatus.empty(); - this.name.error.value = e.toString(); - } catch (e) { - this.name.status.value = RxStatus.empty(); - MessagePopup.error(e.toString()); - rethrow; - } finally { - this.name.editable.value = true; - } - } - } - /// Marks the [chat] as favorited. Future favoriteChat() async { try { diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index 366fc9481cf..d6d7af50ee2 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -49,6 +49,7 @@ import '/ui/widget/svg/svg.dart'; import '/ui/widget/text_field.dart'; import '/ui/widget/widget_button.dart'; import '/util/message_popup.dart'; +import '/util/platform_utils.dart'; import 'controller.dart'; /// View of the [Routes.chatInfo] page. diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index bcd1ddf6507..5f3b8466457 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -198,14 +198,8 @@ class MessageFieldController extends GetxController { /// [GlobalKey] of the text field itself. final GlobalKey fieldKey = GlobalKey(); - final RxInt symbols = RxInt(0); - /// [ChatButton]s displayed in the more panel. late final RxList panel = RxList([ - // const AudioMessageButton(), - // const VideoMessageButton(), - // const DonateButton(), - // const StickerButton(), if (PlatformUtils.isMobile && !PlatformUtils.isWeb) ...[ TakePhotoButton(pickImageFromCamera), if (PlatformUtils.isAndroid) TakeVideoButton(pickVideoFromCamera), diff --git a/lib/ui/page/home/page/chat/message_field/view.dart b/lib/ui/page/home/page/chat/message_field/view.dart index 4581ddf9b6c..825893afc7f 100644 --- a/lib/ui/page/home/page/chat/message_field/view.dart +++ b/lib/ui/page/home/page/chat/message_field/view.dart @@ -54,7 +54,7 @@ import 'widget/chat_button.dart'; import 'widget/close_button.dart'; /// View for writing and editing a [ChatMessage] or a [ChatForward]. -class MessageFieldView extends StatefulWidget { +class MessageFieldView extends StatelessWidget { const MessageFieldView({ super.key, this.controller, @@ -66,7 +66,6 @@ class MessageFieldView extends StatefulWidget { this.canForward = false, this.canAttach = true, this.constraints, - this.symbols = false, }); /// Optionally provided external [MessageFieldController]. @@ -97,8 +96,6 @@ class MessageFieldView extends StatefulWidget { /// [BoxConstraints] replies, attachments and quotes are allowed to occupy. final BoxConstraints? constraints; - final bool symbols; - /// Returns a [ThemeData] to decorate a [ReactiveTextField] with. static ThemeData theme(BuildContext context) { final style = Theme.of(context).style; @@ -133,68 +130,41 @@ class MessageFieldView extends StatefulWidget { ); } - @override - State createState() => _MessageFieldViewState(); -} - -class _MessageFieldViewState extends State { - final GlobalKey _sizeKey = GlobalKey(); - final GlobalKey _backdropKey = GlobalKey(); - @override Widget build(BuildContext context) { final style = Theme.of(context).style; return GetBuilder( - init: widget.controller ?? + init: controller ?? MessageFieldController(Get.find(), Get.find(), Get.find()), global: false, builder: (MessageFieldController c) { return Theme( - data: MessageFieldView.theme(context), + data: theme(context), child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.symbols) - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 12, 4), - child: Text( - 'Symbols: ${c.symbols.value}', - style: style.fonts.small.regular.secondary, - ), - ), - ), - Container( - key: const Key('SendField'), - decoration: BoxDecoration( - borderRadius: style.cardRadius, - boxShadow: [ - CustomBoxShadow( - blurRadius: 8, - color: style.colors.onBackgroundOpacity13, - ), - ], - ), - child: ConditionalBackdropFilter( - condition: style.cardBlur > 0, - filter: ImageFilter.blur( - sigmaX: style.cardBlur, - sigmaY: style.cardBlur, - ), - borderRadius: style.cardRadius, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(c, context), - _buildField(c, context), - ], - ), + child: Container( + key: const Key('SendField'), + decoration: BoxDecoration( + borderRadius: style.cardRadius, + boxShadow: [ + CustomBoxShadow( + blurRadius: 8, + color: style.colors.onBackgroundOpacity13, ), + ], + ), + child: ConditionalBackdropFilter( + condition: style.cardBlur > 0, + filter: ImageFilter.blur( + sigmaX: style.cardBlur, + sigmaY: style.cardBlur, ), - ], + borderRadius: style.cardRadius, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_buildHeader(c, context), _buildField(c, context)], + ), + ), ), ), ); @@ -351,7 +321,7 @@ class _MessageFieldViewState extends State { child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: WidgetButton( - onPressed: () => widget.onItemPressed?.call(e.value), + onPressed: () => onItemPressed?.call(e.value), child: _buildPreview( context, e.value, @@ -369,7 +339,6 @@ class _MessageFieldViewState extends State { } return ConditionalBackdropFilter( - key: _backdropKey, condition: style.cardBlur > 0, filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100), borderRadius: BorderRadius.only( @@ -379,7 +348,6 @@ class _MessageFieldViewState extends State { child: Container( color: style.colors.onPrimaryOpacity50, child: AnimatedSize( - key: _sizeKey, duration: 400.milliseconds, alignment: Alignment.bottomCenter, curve: Curves.ease, @@ -397,8 +365,7 @@ class _MessageFieldViewState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: WidgetButton( - onPressed: () => - widget.onItemPressed?.call(c.edited.value!), + onPressed: () => onItemPressed?.call(c.edited.value!), child: _buildPreview( context, c.edited.value!, @@ -410,7 +377,7 @@ class _MessageFieldViewState extends State { ), if (previews != null) ConstrainedBox( - constraints: widget.constraints ?? + constraints: this.constraints ?? BoxConstraints( maxHeight: max( 100, @@ -450,7 +417,7 @@ class _MessageFieldViewState extends State { ), ), ), - ], + ] ], ), ), @@ -476,7 +443,7 @@ class _MessageFieldViewState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ WidgetButton( - onPressed: widget.canAttach ? c.toggleMore : null, + onPressed: canAttach ? c.toggleMore : null, child: AnimatedButton( child: SizedBox( width: 50, @@ -503,11 +470,8 @@ class _MessageFieldViewState extends State { child: Transform.translate( offset: Offset(0, PlatformUtils.isMobile ? 6 : 1), child: ReactiveTextField( - onChanged: () { - c.symbols.value = c.field.text.length; - widget.onChanged?.call(); - }, - key: widget.fieldKey ?? const Key('MessageField'), + onChanged: onChanged, + key: fieldKey ?? const Key('MessageField'), state: c.field, hint: 'label_send_message_hint'.l10n, minLines: 1, @@ -544,11 +508,10 @@ class _MessageFieldViewState extends State { child: ChatButtonWidget.send( key: c.forwarding.value ? const Key('Forward') - : widget.sendKey ?? const Key('Send'), + : sendKey ?? const Key('Send'), forwarding: c.forwarding.value, onPressed: c.field.submit, - onLongPress: - widget.canForward ? c.forwarding.toggle : null, + onLongPress: canForward ? c.forwarding.toggle : null, ), ); }) @@ -893,7 +856,7 @@ class _MessageFieldViewState extends State { width: 30, borderRadius: BorderRadius.circular(4), onForbidden: () async => - await widget.onAttachmentError?.call(item), + await onAttachmentError?.call(item), ), ); }).toList(), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index d0ef05a3c08..ab6680d54a7 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -348,13 +348,6 @@ class ChatView extends StatelessWidget { right: 10, ), actions: [ - // ContextMenuButton( - // label: 'Bot: ${c.botEnabled.value}', - // onPressed: () { - // c.botEnabled.toggle(); - // c.elements.refresh(); - // }, - // ), ContextMenuButton( label: 'Debug: ${c.showCommands.value}', onPressed: () { @@ -931,23 +924,7 @@ class ChatView extends StatelessWidget { c.selecting.toggle(); c.selected.add(element); }, - onAction: (b) => c.postCommand( - b.command, - repliesTo: e.value, - ), - actions: [ - // if (c.botEnabled.value) - if (c.chat?.bots - .any((e) => e.title == 'Translation Service') == - true) - ContextMenuButton( - onPressed: () => c.postCommand( - '/translate', - repliesTo: e.value, - ), - label: 'Translate', - ), - ], + onAction: (b) => c.postCommand(b.command, repliesTo: e.value), infos: element is ChatMessageElement ? element.infos.values.toList() : [], @@ -1141,7 +1118,6 @@ class ChatView extends StatelessWidget { }); } else if (element is BotInfoElement) { const Color color = Color.fromARGB(255, 149, 209, 149); - // const Color color = Color(0xFFddf1f4); return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), @@ -1570,14 +1546,12 @@ class ChatView extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, - // scrollDirection: Axis.horizontal, children: [ if (c.botInfo.value!.string != null) Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: color, width: 0.5), - // color: const Color(0xFFddf1f4), gradient: const LinearGradient( stops: [0, 1], colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], @@ -1601,17 +1575,6 @@ class ChatView extends StatelessWidget { onPressed: () => c.postCommand(e.command), child: Stack( children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -1620,21 +1583,10 @@ class ChatView extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(6), border: Border.all( - // color: style.systemMessageColor, color: const Color(0xFFD3E3E6), - // color: const Color(0xFFCEE1E4), width: 1, ), color: const Color(0xFFddf1f4), - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [ - // Color(0xFFE1F0F3), - // Color(0xFFBDF4FF) - // ], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), ), child: Text( e.text, diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index d76557d4f6b..201227d05f0 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -852,102 +852,33 @@ class _ChatItemWidgetState extends State { Widget _botInfo(BuildContext context, BotInfo e) { final style = Theme.of(context).style; - final ChatMessage msg = widget.item.value as ChatMessage; - if (e.text?.val.isEmpty == true) { return const SizedBox(); } - // const Color color = Color.fromARGB(255, 149, 209, 149); - - final InputBorder border = OutlineInputBorder( - borderSide: BorderSide( - // color: style.primaryBorder.top.color, - color: const Color(0xFFD3E3E6), - width: style.primaryBorder.top.width, - ), - borderRadius: BorderRadius.circular(15), - ); - - return Stack( - children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - // color: const Color(0xFFddf1f4), - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), - ), - padding: const EdgeInsets.fromLTRB(10, 2, 10, 10), - // decoration: InputDecoration( - // // label: Text(e.title), - // floatingLabelStyle: style.fonts.small.regular.secondary, - // filled: true, - // // fillColor: Colors.white.withOpacity(0.34), - // // fillColor: const Color(0xFFddf1f4), - // floatingLabelAlignment: FloatingLabelAlignment.center, - // focusedBorder: border, - // errorBorder: border, - // enabledBorder: border, - // disabledBorder: border, - // focusedErrorBorder: border, - // contentPadding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - // isCollapsed: true, - // // contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - // border: border, - // ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Text('${e.text}', style: style.fonts.smallest.regular.secondary), - if (e.text != null) - MarkdownWidget( - e.text!.val, - // style: style.systemMessageStyle, - style: style.fonts.smaller.regular.secondary, - ), - if (e.actions != null && e.text != null) - const SizedBox(height: 2), - if (e.actions != null) ...[ - Row( - mainAxisSize: MainAxisSize.min, - // spacing: 2, - // runSpacing: 2, - // crossAxisAlignment: WrapCrossAlignment.end, - // alignment: WrapAlignment.end, - children: e.actions!.map((e) { + return Container( + width: double.infinity, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(15)), + padding: const EdgeInsets.fromLTRB(10, 2, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (e.text != null) + MarkdownWidget( + e.text!.val, + style: style.fonts.smaller.regular.secondary, + ), + if (e.actions != null && e.text != null) const SizedBox(height: 2), + if (e.actions != null) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: e.actions! + .map((e) { return SelectionContainer.disabled( child: WidgetButton( onPressed: () => widget.onAction?.call(e), child: Stack( children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -960,12 +891,6 @@ class _ChatItemWidgetState extends State { width: 1, ), color: const Color(0xFFddf1f4), - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), ), child: Text( e.text, @@ -979,64 +904,35 @@ class _ChatItemWidgetState extends State { ), ), ); - }).between((_, __) { - return const SizedBox(width: 8); - }).toList(), - ), - ], - ], - ), - ), - ], + }) + .between((_, __) => const SizedBox(width: 8)) + .toList(), + ), + ], + ], + ), ); } Widget _renderAsBotInfo(BuildContext context) { final style = Theme.of(context).style; - // return const SizedBox(); - final ChatMessage msg = widget.item.value as ChatMessage; final BotInfo info = _bot!; - // const Color color = Color.fromARGB(255, 149, 209, 149); - // const Color color = Color(0xFFb68ad1); - // const Color color = Color(0xFF1f8429); - // const Color color = Color(0xFFD2E3F9); - // const Color color = Color.fromARGB(255, 228, 228, 228); - const Color color = Color(0xFFddf1f4); - // const Color color = Color.fromARGB(255, 161, 255, 183); return Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), child: Center( child: Stack( children: [ - // Positioned.fill( - // child: ClipRRect( - // borderRadius: BorderRadius.circular(15), - // child: const SvgImage.asset( - // 'assets/images/bot_texture.svg', - // width: double.infinity, - // height: double.infinity, - // fit: BoxFit.cover, - // ), - // ), - // ), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), border: Border.all(color: const Color(0xFFD3E3E6), width: 0.5), - // color: style.systemMessageColor, color: color, - // gradient: const LinearGradient( - // stops: [0, 1], - // colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - // begin: Alignment.topCenter, - // end: Alignment.bottomCenter, - // ), ), child: IntrinsicWidth( child: Column( @@ -1047,7 +943,6 @@ class _ChatItemWidgetState extends State { info.text!.val, style: style.systemMessageStyle, ), - // Text('${info.text}', style: style.systemMessageStyle), if (info.actions != null) ...[ const SizedBox(height: 4), Wrap( diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index dd04c703374..25b8c036608 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -21,6 +21,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:messenger/domain/service/my_user.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -106,13 +107,6 @@ class UserController extends GetxController { /// [GlobalKey] of the more [ContextMenuRegion] button. final GlobalKey moreKey = GlobalKey(); - /// [ItemScrollController] of the profile's [ScrollablePositionedList]. - final ItemScrollController itemScrollController = ItemScrollController(); - - /// [ItemPositionsListener] of the profile's [ScrollablePositionedList]. - final ItemPositionsListener positionsListener = - ItemPositionsListener.create(); - /// [TextFieldState] for blocking reason. final TextFieldState reason = TextFieldState(); @@ -136,9 +130,6 @@ class UserController extends GetxController { /// be highlighted. final RxnInt highlighted = RxnInt(); - /// Index of the [Block] that should be highlighted. - final RxnInt highlighted = RxnInt(); - /// [UserService] fetching the [user]. final UserService _userService; @@ -160,13 +151,6 @@ class UserController extends GetxController { /// updating the [name]. Worker? _worker; - /// [Timer] resetting the [highlight] value after the [_highlightTimeout] has - /// passed. - Timer? _highlightTimer; - - /// [Duration] of the [highlight]ing. - static const Duration _highlightTimeout = Duration(seconds: 1); - /// Subscription for the [user] changes. StreamSubscription? _userSubscription; @@ -356,54 +340,6 @@ class UserController extends GetxController { } } - /// Renames the [ChatContact] this [User] is linked to. - /// - /// If no [ChatContact] is linked, then this method creates one. - Future submitName() async { - name.error.value = null; - name.focus.unfocus(); - - if (name.text == contact.value?.contact.value.name.val) { - name.unsubmit(); - return; - } - - UserName? userName; - try { - userName = UserName(name.text); - } on FormatException catch (_) { - name.status.value = RxStatus.empty(); - name.error.value = 'err_incorrect_input'.l10n; - name.unsubmit(); - return; - } - - if (name.error.value == null) { - if (contactId == null) { - await addToContacts(name: userName); - return; - } - - name.status.value = RxStatus.loading(); - name.editable.value = false; - - try { - await _contactService.changeContactName(contact.value!.id, userName); - name.status.value = RxStatus.empty(); - name.unsubmit(); - } on UpdateChatContactNameException catch (e) { - name.status.value = RxStatus.empty(); - name.error.value = e.toString(); - } catch (e) { - name.status.value = RxStatus.empty(); - MessagePopup.error(e.toString()); - rethrow; - } finally { - name.editable.value = true; - } - } - } - // TODO: Replace with GraphQL mutation when implemented. /// Reports the [user]. Future report() async { diff --git a/lib/ui/page/home/widget/avatar.dart b/lib/ui/page/home/widget/avatar.dart index 7b4bf8a0fae..a30ae147922 100644 --- a/lib/ui/page/home/widget/avatar.dart +++ b/lib/ui/page/home/widget/avatar.dart @@ -83,7 +83,6 @@ class AvatarWidget extends StatelessWidget { this.onForbidden, this.shape = BoxShape.circle, this.child, - this.shape = BoxShape.circle, }); /// Creates an [AvatarWidget] from the specified [contact]. @@ -360,9 +359,6 @@ class AvatarWidget extends StatelessWidget { /// Intended to be used on the [Routes.style] page only. final Widget? child; - /// [BoxShape] to display this [AvatarWidget] of. - final BoxShape shape; - /// Returns minimum diameter of the avatar. double get _minDiameter { if (radius == null) { diff --git a/lib/ui/page/home/widget/contact_tile.dart b/lib/ui/page/home/widget/contact_tile.dart index 8c937ea0139..bc8639cc117 100644 --- a/lib/ui/page/home/widget/contact_tile.dart +++ b/lib/ui/page/home/widget/contact_tile.dart @@ -192,7 +192,6 @@ class ContactTile extends StatelessWidget { ], ), ), - // if (user?.isBot == true) const Text('Bot'), if (user?.isBot == true) Icon( Icons.smart_toy, From 0ab464627e06059891a3e6bd19eca61f708e475d Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 9 May 2024 16:57:45 +0300 Subject: [PATCH 69/88] Add members to `User` page --- lib/ui/page/home/page/chat/info/view.dart | 4 + lib/ui/page/home/page/user/view.dart | 115 ++++++++++++++++++++++ lib/ui/page/login/view.dart | 1 + 3 files changed, 120 insertions(+) diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index 907b95cf19e..2aab5dee790 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -325,6 +325,10 @@ class ChatInfoView extends StatelessWidget { } } + for (var u in c.chat!.bots) { + members.add(u); + } + return Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 2cf7f18a1d2..054ddd44102 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -19,6 +19,10 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:messenger/domain/model/my_user.dart'; +import 'package:messenger/domain/repository/user.dart'; +import 'package:messenger/ui/page/home/page/chat/info/add_member/view.dart'; +import 'package:messenger/ui/widget/member_tile.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '/domain/model/chat.dart'; @@ -100,6 +104,14 @@ class UserView extends StatelessWidget { _avatar(c, context), _name(c, context, index: c.isBlocked != null ? 3 : 2), _info(c), + Obx(() { + final RxUser user = c.user!; + if (user.dialog.value != null) { + return _members(c, context); + } + + return const SizedBox(); + }), SelectionContainer.disabled( child: Block(children: [_actions(c, context)]), ), @@ -287,6 +299,109 @@ class UserView extends StatelessWidget { ); } + /// Returns the [Block] displaying the [Chat.members]. + Widget _members(UserController c, BuildContext context) { + final style = Theme.of(context).style; + final RxChat dialog = c.user!.dialog.value!; + + return Block( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), + title: 'label_participants'.l10nfmt({'count': 2}), + children: [ + Obx(() { + final List members = []; + + for (var u in dialog.members.values) { + if (u.user.id != c.me) { + members.add(u.user); + } + } + + for (var u in dialog.bots) { + members.add(u); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 500), + child: ListView.builder( + key: const Key('ChatMembers'), + shrinkWrap: true, + itemCount: members.length + 1, + itemBuilder: (_, i) { + i--; + + Widget child; + + final bool hasCall = dialog.chat.value.ongoingCall != null; + + if (i == -1) { + final MyUser? myUser = c.myUser.value; + final bool inCall = dialog.inCall.value == true; + + child = MemberTile( + myUser: myUser, + inCall: hasCall ? inCall : null, + onCall: inCall + ? () { + if (myUser != null) { + c.removeChatCallMember(myUser.id); + } + } + : c.joinCall, + ); + } else { + final RxUser member = members[i]; + + final bool inCall = dialog.chat.value.ongoingCall?.members + .any((u) => u.user.id == member.id) == + true; + + child = MemberTile( + user: member, + inCall: hasCall ? inCall : null, + onTap: () => + router.chat(member.user.value.dialog, push: true), + onCall: inCall + ? () => c.removeChatCallMember(member.id) + : () => c.redialChatCallMember(member.id), + onKick: member.isBot + ? () async { + dialog.removeBot(member); + } + : null, + ); + } + + return Padding( + padding: const EdgeInsets.only(right: 10, left: 10), + child: child, + ); + }, + ), + ), + ], + ); + }), + const SizedBox(height: 16), + SelectionContainer.disabled( + child: WidgetButton( + onPressed: () async => await AddChatMemberView.show( + context, + chatId: dialog.id, + ), + child: Text( + 'btn_add_participant'.l10n, + style: style.fonts.small.regular.primary, + ), + ), + ), + ], + ); + } + /// Returns information about the [User] and related to it action buttons in /// the [CustomAppBar]. Widget _bar(UserController c, BuildContext context) { diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index e66fb7d9e14..c00b02271a9 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -19,6 +19,7 @@ import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/widget/outlined_rounded_button.dart'; import '/domain/model/user.dart'; import '/l10n/l10n.dart'; From 6b914eaf6048de0a863c1ffc4bfa43ed62cd5a79 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 9 May 2024 17:28:09 +0300 Subject: [PATCH 70/88] Merge branch '312-impl-account-switching-ui' into stable-design --- CHANGELOG.md | 3 + assets/icons/logout.svg | 1 + assets/icons/logout_white.svg | 1 + assets/l10n/en-US.ftl | 14 +- assets/l10n/ru-RU.ftl | 12 + lib/domain/model/session.dart | 3 + lib/domain/repository/auth.dart | 18 +- lib/domain/repository/my_user.dart | 11 +- lib/domain/service/auth.dart | 508 +++++++++++++----- lib/domain/service/my_user.dart | 13 +- lib/provider/gql/base.dart | 4 +- lib/provider/gql/components/auth.dart | 21 +- lib/routes.dart | 20 + lib/store/auth.dart | 45 +- lib/store/my_user.dart | 77 ++- lib/ui/page/home/page/user/controller.dart | 2 +- .../home/tab/menu/accounts/controller.dart | 503 +++++++++++++++++ lib/ui/page/home/tab/menu/accounts/view.dart | 508 ++++++++++++++++++ lib/ui/page/home/tab/menu/controller.dart | 7 +- lib/ui/page/home/tab/menu/view.dart | 23 + lib/ui/page/popup_call/controller.dart | 5 +- lib/ui/widget/svg/svgs.dart | 12 + lib/util/log.dart | 14 + lib/util/web/non_web.dart | 20 +- lib/util/web/web.dart | 51 +- test/e2e/steps/users.dart | 8 +- 26 files changed, 1678 insertions(+), 226 deletions(-) create mode 100644 assets/icons/logout.svg create mode 100644 assets/icons/logout_white.svg create mode 100644 lib/ui/page/home/tab/menu/accounts/controller.dart create mode 100644 lib/ui/page/home/tab/menu/accounts/view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4438b417b..87a54a8e9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All user visible changes to this project will be documented in this file. This p - UI: - Profile page: - Connected devices list. ([#986], [#566]) + - Accounts switching. ([#975], [#312]) ### Changed @@ -35,9 +36,11 @@ All user visible changes to this project will be documented in this file. This p - Chat page: - Tapping on image opens gallery when being in context menu. ([#983], [#979]) +[#312]: /../../issues/312 [#566]: /../../issues/566 [#948]: /../../issues/948 [#950]: /../../issues/950 +[#975]: /../../pull/975 [#978]: /../../pull/978 [#979]: /../../issues/979 [#980]: /../../pull/980 diff --git a/assets/icons/logout.svg b/assets/icons/logout.svg new file mode 100644 index 00000000000..ec2b8b71cab --- /dev/null +++ b/assets/icons/logout.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/logout_white.svg b/assets/icons/logout_white.svg new file mode 100644 index 00000000000..64c6e547ee1 --- /dev/null +++ b/assets/icons/logout_white.svg @@ -0,0 +1 @@ + diff --git a/assets/l10n/en-US.ftl b/assets/l10n/en-US.ftl index f4e1dfd612a..bc0b4e5378f 100644 --- a/assets/l10n/en-US.ftl +++ b/assets/l10n/en-US.ftl @@ -72,6 +72,10 @@ alert_user_will_be_reported2 = {" "}will be reported. alert_you_will_leave_group = You will leave the group. btn_accept = Accept btn_add = Add +btn_add_account = Add account +btn_add_account_with_desc = + Add + account btn_add_member = Add member btn_add_participant = Add participant btn_add_participant_desc = @@ -170,6 +174,9 @@ btn_call_video_on_desc = Turn video on btn_change = Change +btn_change_account_desc = + Change + account btn_change_avatar = Change avatar btn_change_password = Change password btn_clear_cache = Clear cache @@ -298,6 +305,7 @@ email_verification_code = {$domain} email_verification_code_subject = {$domain} verification code err_account_not_found = Indicated account is not found +err_account_unavailable = This account is not available. Please, log in again. err_blocked = You've been added to the blocklist of this user. err_blocked_by = {$user} has added you to their blocklist. err_blocked_by_multiple = One or more of the selected users have added you to their blocklist. @@ -510,8 +518,11 @@ label_a_of_b = {$a} of {$b} label_a_slash_b = {$a} / {$b} label_about = About label_account = Account +label_accounts = Your accounts label_account_created = Account is created label_actions = Actions +label_active_account = Active +label_add_account = Add account label_add_additional_email = Add additional E-mail label_add_additional_number = Add additional number label_add_chat_member = Add member @@ -734,6 +745,7 @@ label_duration_second_short = s label_edit = Edit label_email = E-mail label_email_not_verified = E-mail not verified +label_email_or_phone_not_set = E-mail or phone number is not set. Access to the account will be lost. label_email_visible = Yor E-mail visible to:{" "} label_email_example = example@gmail.com label_empty_message = Empty message @@ -881,7 +893,7 @@ label_participants = Participants: {$count} label_participants_of = Participants: {$a} of {$b} label_password = Password label_password_changed = Password has been changed. -label_password_not_set = Password not set. Access to the account will be lost. +label_password_not_set = Password is not set. Access to the account will be lost. label_password_not_set_info = No password has been set for your account. Consequently: • access to your account will be lost forever when you close the current window; diff --git a/assets/l10n/ru-RU.ftl b/assets/l10n/ru-RU.ftl index 4e55ac60178..3f14544499a 100644 --- a/assets/l10n/ru-RU.ftl +++ b/assets/l10n/ru-RU.ftl @@ -72,6 +72,10 @@ alert_user_will_be_reported2 = {" "}будет отправлена жалоба alert_you_will_leave_group = Вы покинете группу. btn_accept = Принять btn_add = Добавить +btn_add_account = Добавить аккаунт +btn_add_account_with_desc = + Добавить + аккаунт btn_add_member = Добавить участника btn_add_participant = Добавить участника btn_add_participant_desc = @@ -169,6 +173,9 @@ btn_cancel = Отменить btn_call_video_on_desc = Включить камеру +btn_change_account_desc = + Сменить + аккаунт btn_change = Изменить btn_change_avatar = Изменить аватар btn_change_password = Сменить пароль @@ -298,6 +305,7 @@ email_verification_code = {$domain} email_verification_code_subject = Код подтверждения {$domain} err_account_not_found = Указанный аккаунт не найден +err_account_unavailable = Этот аккаунт недоступен. Пожалуйста, повторите авторизацию. err_blocked = Пользователь добавил Вас в чёрный список. err_blocked_by = {$user} добавил Вас в чёрный список. err_blocked_by_multiple = Один или несколько выбранных пользователей внесли Вас в чёрный список. @@ -521,8 +529,11 @@ label_a_of_b = {$a} из {$b} label_a_slash_b = {$a} / {$b} label_about = О себе label_account = Аккаунт +label_accounts = Ваши аккаунты label_account_created = Аккаунт создан label_actions = Действия +label_active_account = Текущий +label_add_account = Добавить аккаунт label_add_additional_email = Добавить дополнительный E-mail label_add_additional_number = Добавить дополнительный телефон label_add_chat_member = Добавление участника @@ -759,6 +770,7 @@ label_duration_second_short = с label_edit = Редактировать label_email = E-mail label_email_not_verified = E-mail не верифицирован +label_email_or_phone_not_set = E-mail или номер телефона не задан. Восстановление доступа к аккаунту невозможно. label_email_example = example@gmail.com label_email_visible = Ваш E-mail видят:{" "} label_empty_message = Пустое сообщение diff --git a/lib/domain/model/session.dart b/lib/domain/model/session.dart index bddb372244c..f870d1b391a 100644 --- a/lib/domain/model/session.dart +++ b/lib/domain/model/session.dart @@ -184,4 +184,7 @@ class Credentials { 'userId': userId.val, }; } + + @override + String toString() => 'Credentials(userId: $userId)'; } diff --git a/lib/domain/repository/auth.dart b/lib/domain/repository/auth.dart index d53a7bae0b8..722364f8603 100644 --- a/lib/domain/repository/auth.dart +++ b/lib/domain/repository/auth.dart @@ -62,8 +62,9 @@ abstract class AbstractAuthRepository { UserPhone? phone, }); - /// Invalidates a [Session] with the provided [id], if any, or otherwise - /// [Session] of the [MyUser] identified by the [token]. + /// Invalidates a [Session] with the provided [id] of the [MyUser] identified + /// by the [accessToken], if any, or otherwise [Session] of the [MyUser] + /// identified by the [token]. /// /// Unregisters a device (Android, iOS, or Web) from receiving notifications /// via Firebase Cloud Messaging, if [fcmToken] is provided. @@ -71,6 +72,7 @@ abstract class AbstractAuthRepository { SessionId? id, UserPassword? password, FcmRegistrationToken? fcmToken, + AccessTokenSecret? accessToken, }); /// Deletes the [MyUser] identified by the provided [id] from the accounts. @@ -91,8 +93,8 @@ abstract class AbstractAuthRepository { /// [signUpWithEmail]. Future resendSignUpEmail(); - /// Validates the current [AccessToken]. - Future validateToken(); + /// Validates the [AccessToken] of the provided [Credentials]. + Future validateToken(Credentials credentials); /// Refreshes the current [AccessToken]. /// @@ -102,7 +104,13 @@ abstract class AbstractAuthRepository { /// The renewed [Session] has its own expiration after renewal, so to renew it /// again use this method with the new returned [RefreshToken] (omit using /// old ones). - Future refreshSession(RefreshTokenSecret secret); + /// + /// If [reconnect] is `true`, then applies the retrieved [Credentials] as the + /// [token] right away. + Future refreshSession( + RefreshTokenSecret secret, { + bool reconnect, + }); /// Initiates password recovery for a [MyUser] identified by the provided /// [num]/[login]/[email]/[phone] (exactly one of fourth should be specified). diff --git a/lib/domain/repository/my_user.dart b/lib/domain/repository/my_user.dart index c14e7061439..d863d954f3d 100644 --- a/lib/domain/repository/my_user.dart +++ b/lib/domain/repository/my_user.dart @@ -22,12 +22,21 @@ import '/domain/model/mute_duration.dart'; import '/domain/model/my_user.dart'; import '/domain/model/native_file.dart'; import '/domain/model/user.dart'; +import '/util/obs/rxmap.dart'; /// [MyUser] repository interface. abstract class AbstractMyUserRepository { - /// Returns stored [MyUser] value. + /// Returns the currently active [MyUser] profile. Rx get myUser; + /// Returns a reactive map of known [MyUser] profiles. + /// + /// __Note__, that having a [MyUser] here doesn't mean that + /// [AbstractAuthRepository] can sign into that account: it must also have + /// non-stale [Credentials], which can be found in [AuthService.sessions] + /// field. + RxObsMap> get profiles; + /// Initializes the repository. /// /// Callback [onUserDeleted] should be called when [myUser] is deleted. diff --git a/lib/domain/service/auth.dart b/lib/domain/service/auth.dart index 0aec3f30aac..3a7df34084c 100644 --- a/lib/domain/service/auth.dart +++ b/lib/domain/service/auth.dart @@ -18,6 +18,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart' show visibleForTesting; import 'package:get/get.dart'; @@ -63,6 +64,12 @@ class AuthService extends GetxService { /// - `status.isSuccess` meaning successful authorization. final Rx status = Rx(RxStatus.loading()); + /// [Credentials] of the available accounts. + /// + /// If there're no [Credentials] for the given [UserId], then their + /// [Credentials] should be considered as stale. + final RxMap> accounts = RxMap(); + /// [CredentialsHiveProvider] used to store user [Session]. final CredentialsHiveProvider _credentialsProvider; @@ -72,9 +79,9 @@ class AuthService extends GetxService { /// Authorization repository containing required authentication methods. final AbstractAuthRepository _authRepository; - /// [Timer] used to periodically check the [Session.expireAt] and refresh it - /// if necessary. - Timer? _refreshTimer; + /// [Timer]s used to periodically check and refresh [Session]s of available + /// accounts. + final Map _refreshTimers = {}; /// [_refreshTimer] interval. final Duration _refreshTaskInterval = const Duration(seconds: 30); @@ -86,10 +93,6 @@ class AuthService extends GetxService { /// [Credentials] to the browser's storage. StreamSubscription? _credentialsSubscription; - /// [StreamSubscription] to [AccountHiveProvider.boxEvents] saving new - /// [Credentials] to the browser's storage. - StreamSubscription? _accountSubscription; - /// [StreamSubscription] to [WebUtils.onStorageChange] fetching new /// [Credentials]. StreamSubscription? _storageSubscription; @@ -100,13 +103,6 @@ class AuthService extends GetxService { /// Returns the reactive list of active [Session]s. RxList get sessions => _authRepository.sessions; - /// Indicates whether the [credentials] require a refresh. - bool get _shouldRefresh => - credentials.value?.access.expireAt - .subtract(_accessTokenMinTtl) - .isBefore(PreciseDateTime.now().toUtc()) == - true; - /// Indicates whether this [AuthService] is considered authorized. bool get _hasAuthorization => credentials.value != null; @@ -116,8 +112,8 @@ class AuthService extends GetxService { _storageSubscription?.cancel(); _credentialsSubscription?.cancel(); - _accountSubscription?.cancel(); - _refreshTimer?.cancel(); + _refreshTimers.forEach((_, t) => t.cancel()); + _refreshTimers.clear(); } /// Initializes this service. @@ -128,8 +124,8 @@ class AuthService extends GetxService { String? init() { Log.debug('init()', '$runtimeType'); - // Try to refresh session, otherwise just force logout. _authRepository.authExceptionHandler = (e) async { + // Try to refresh session, otherwise just force logout. if (credentials.value?.refresh.expireAt .isAfter(PreciseDateTime.now().toUtc()) == true) { @@ -141,99 +137,116 @@ class AuthService extends GetxService { } }; - final UserId? userId = _accountProvider.userId; - Credentials? creds = - userId != null ? _credentialsProvider.get(userId) : null; - AccessToken? access = creds?.access; - RefreshToken? refresh = creds?.refresh; - - // Listen to the [Credentials] changes. + // Listen to the [Credentials] changes to stay synchronized with another + // tabs. _storageSubscription = WebUtils.onStorageChange.listen((e) { - Log.debug( - '_storageSubscription(${e.key}): received new credentials', - '$runtimeType', - ); - - if (e.key == 'credentials') { + if (e.key?.startsWith('credentials_') ?? false) { + Log.debug( + '_storageSubscription(${e.key}): received a credentials update', + '$runtimeType', + ); if (e.newValue != null) { - final Credentials creds = + final Credentials received = Credentials.fromJson(json.decode(e.newValue!)); + Credentials? current = credentials.value; final bool authorized = _hasAuthorization; - if (creds.access.secret != credentials.value?.access.secret && - (creds.userId == credentials.value?.userId || !authorized)) { - _authRepository.token = creds.access.secret; + if (!authorized || + received.userId == current?.userId && + received.access.secret != current?.access.secret) { + // These [Credentials] should be treated as current ones, so just + // apply them as saving to [Hive] has already been performed by + // another tab. + _authRepository.token = received.access.secret; _authRepository.applyToken(); - credentials.value = creds; + credentials.value = received; + _putCredentials(received); status.value = RxStatus.success(); if (!authorized) { router.home(); } + } else { + current = accounts[received.userId]?.value; + if (received.access.secret != current?.access.secret) { + // These [Credentials] are of another account, so just save them. + _putCredentials(received); + } } } else { - if (!WebUtils.isPopup) { + final UserId? deletedId = accounts.keys + .firstWhereOrNull((k) => e.key?.endsWith(k.val) ?? false); + + accounts.remove(deletedId); + + final bool currentAreNull = credentials.value == null; + final bool currentDeleted = + deletedId != null && deletedId == this.userId; + + if ((currentAreNull || currentDeleted) && !WebUtils.isPopup) { router.go(_unauthorized()); } } } }); - WebUtils.credentials = creds; _credentialsSubscription = _credentialsProvider.boxEvents.listen((e) { - if (e.deleted) { - // No-op, handled in [_accountSubscription]. - return; - } - - // Check [_accountProvider] to determine whether these [Credentials] are - // of the active account. - final UserId? current = - WebUtils.credentials?.userId ?? _accountProvider.userId; - - if (e.key == current?.val) { - WebUtils.credentials = e.value; - } - }); + Log.debug( + '_credentialsSubscription event deleted: ${e.deleted}, ${e.key}', + '$runtimeType', + ); - _accountSubscription = _accountProvider.boxEvents.listen((e) { if (e.deleted) { - WebUtils.credentials = null; + WebUtils.removeCredentials(UserId(e.key as String)); } else { - final UserId id = e.value; - final Credentials? creds = _credentialsProvider.get(id); - - // [creds] may still be `null` here if [Credentials] haven't been put to - // [Hive] yet. Still update [WebUtils.credentials] so that this case - // can be handled by the [_credentialsSubscription]'s event handler. - WebUtils.credentials = creds; + WebUtils.putCredentials(e.value); } }); - if (access == null) { + for (final Credentials e in _credentialsProvider.valuesSafe) { + WebUtils.putCredentials(e); + _putCredentials(e); + } + + final UserId? userId = _accountProvider.userId; + final Credentials? creds = + userId != null ? _credentialsProvider.get(userId) : null; + + if (creds == null) { return _unauthorized(); - } else { - if (refresh == null) { - if (access.expireAt.isAfter(PreciseDateTime.now().toUtc())) { - _authorized(creds!); - status.value = RxStatus.success(); - return null; - } - } else if (refresh.expireAt.isAfter(PreciseDateTime.now().toUtc())) { - _authorized(creds!); - if (access.expireAt - .subtract(_accessTokenMinTtl) - .isBefore(PreciseDateTime.now().toUtc())) { - refreshSession(); - } - status.value = RxStatus.success(); - return null; - } + } + final AccessToken access = creds.access; + final RefreshToken refresh = creds.refresh; + + if (access.expireAt.isAfter(PreciseDateTime.now().toUtc())) { + _authorized(creds); + status.value = RxStatus.success(); + return null; + } else if (refresh.expireAt.isAfter(PreciseDateTime.now().toUtc())) { + _authorized(creds); + + if (_shouldRefresh(creds)) { + refreshSession(); + } + status.value = RxStatus.success(); + return null; + } else { + // Neither [AccessToken] nor [RefreshToken] are valid, should logout. return _unauthorized(); } } + /// Returns authorization status of the [MyUser] identified by the provided + /// [UserId], if [userId] is non-`null`, or of the active [MyUser] otherwise. + bool isAuthorized([UserId? userId]) { + if (userId == null || userId == credentials.value?.userId) { + return _hasAuthorization; + } + + return accounts[userId]?.value != null; + } + /// Initiates password recovery for a [MyUser] identified by the provided /// [num]/[login]/[email]/[phone] (exactly one of fourth should be specified). /// @@ -322,17 +335,18 @@ class AuthService extends GetxService { /// Once the created [Session] expires, the created [MyUser] looses access, if /// he doesn't re-sign in within that period of time. /// - /// If [status] is already authorized, then this method does nothing. - Future register() async { - Log.debug('register()', '$runtimeType'); + /// If [status] is already authorized, then this method does nothing, however, + /// this logic can be ignored by specifying [force] as `true`. + Future register({bool force = false}) async { + Log.debug('register(force: $force)', '$runtimeType'); - status.value = RxStatus.loading(); + status.value = force ? RxStatus.loadingMore() : RxStatus.loading(); await WebUtils.protect(() async { // If service is already authorized, then no-op, as this operation is - // meant to be invoked only during unauthorized phase, or otherwise the - // dependencies will be broken as of now. - if (_hasAuthorization) { + // meant to be invoked only during unauthorized phase or account + // switching, or otherwise the dependencies will be broken as of now. + if (!force && _hasAuthorization) { return; } @@ -341,7 +355,11 @@ class AuthService extends GetxService { _authorized(data); status.value = RxStatus.success(); } catch (e) { - _unauthorized(); + if (force) { + status.value = RxStatus.success(); + } else { + _unauthorized(); + } rethrow; } }); @@ -360,17 +378,21 @@ class AuthService extends GetxService { /// Confirms the [signUpWithEmail] with the provided [ConfirmationCode]. /// - /// If [status] is already authorized, then this method does nothing. - Future confirmSignUpEmail(ConfirmationCode code) async { - Log.debug('confirmSignUpEmail($code)', '$runtimeType'); + /// If [status] is already authorized, then this method does nothing, however, + /// this logic can be ignored by specifying [force] as `true`. + Future confirmSignUpEmail( + ConfirmationCode code, { + bool force = false, + }) async { + Log.debug('confirmSignUpEmail($code, force: $force)', '$runtimeType'); - status.value = RxStatus.loading(); + status.value = force ? RxStatus.loadingMore() : RxStatus.loading(); await WebUtils.protect(() async { // If service is already authorized, then no-op, as this operation is // meant to be invoked only during unauthorized phase, or otherwise the // dependencies will be broken as of now. - if (_hasAuthorization) { + if (!force && _hasAuthorization) { return; } @@ -379,7 +401,12 @@ class AuthService extends GetxService { _authorized(data); status.value = RxStatus.success(); } catch (e) { - _unauthorized(); + if (force) { + status.value = RxStatus.success(); + } else { + _unauthorized(); + } + rethrow; } }); @@ -399,28 +426,30 @@ class AuthService extends GetxService { /// /// Throws [CreateSessionException]. /// - /// If [status] is already authorized, then this method does nothing, however - /// this logic can be ignored by specifying [force] as `true`, but be careful, - /// as this also ignores possible [WebUtils.protect] races - you may want to - /// lock it before invoking this method to be async-safe. + /// If [status] is already authorized, then this method does nothing, however, + /// this logic can be ignored by specifying [force] as `true`. + /// + /// If [unsafe] is `true` then this method ignores possible [WebUtils.protect] + /// races - you may want to lock it before invoking this method to be + /// async-safe. Future signIn( UserPassword password, { UserLogin? login, UserNum? num, UserEmail? email, UserPhone? phone, + bool unsafe = false, bool force = false, }) async { Log.debug( - 'signIn(***, login: $login, num: $num, email: ***, phone: ***, force: $force)', + 'signIn(***, login: $login, num: $num, email: ***, phone: ***, unsafe: $unsafe, force: $force)', '$runtimeType', ); - // If [force] is `true`, then [WebUtils.protect] is ignored. - final Function protect = force ? (fn) => fn() : WebUtils.protect; + // If [ignoreLock] is `true`, then [WebUtils.protect] is ignored. + final Function protect = unsafe ? (fn) => fn() : WebUtils.protect; - status.value = - credentials.value == null ? RxStatus.loading() : RxStatus.loadingMore(); + status.value = force ? RxStatus.loadingMore() : RxStatus.loading(); await protect(() async { // If service is already authorized, then no-op, as this operation is // meant to be invoked only during unauthorized phase, or otherwise the @@ -440,7 +469,12 @@ class AuthService extends GetxService { _authorized(creds); status.value = RxStatus.success(); } catch (e) { - _unauthorized(); + if (force) { + status.value = RxStatus.success(); + } else { + _unauthorized(); + } + rethrow; } }); @@ -533,13 +567,93 @@ class AuthService extends GetxService { return await deleteSession() ?? Routes.auth; } - /// Validates the current [AccessToken]. - Future validateToken() async { - Log.debug('validateToken()', '$runtimeType'); + /// Switches to the account with the provided [UserId] using the persisted + /// [Credentials]. + /// + /// Returns `true` if the account was successfully switched, otherwise returns + /// `false`. + Future switchAccount(UserId userId) async { + Log.debug('switchAccount($userId)', '$runtimeType'); + + Credentials? creds = accounts[userId]?.value; + if (creds == null) { + return false; + } + + status.value = RxStatus.loading(); + + try { + if (_shouldRefresh(creds)) { + await refreshSession(userId: creds.userId); + } + + creds = accounts[userId]?.value; + if (creds == null) { + return false; + } + + // TODO: Remove, when remote subscription to each [MyUser] events is up. + // + // This workarounds the situation when the password of another account was + // changed or the account was deleted. + final bool areValid = await validateToken(creds); + if (areValid) { + await WebUtils.protect(() async { + _authorized(creds!); + status.value = RxStatus.success(); + }); + + return true; + } else { + status.value = RxStatus.success(); + _credentialsProvider.remove(userId); + accounts.remove(userId); + } + } catch (_) { + status.value = RxStatus.success(); + rethrow; + } + + return false; + } + + /// Deletes the [MyUser] identified by the provided [id] from the accounts and + /// invalidates their [Session]. + Future removeAccount(UserId id) async { + Log.debug('removeAccount($id)', '$runtimeType'); + + _authRepository.removeAccount(id); + + // Delete [Session] for this account if it's not the current one. + final AccessTokenSecret? token = accounts[id]?.value.access.secret; + if (id != userId && token != null) { + await _authRepository.deleteSession(accessToken: token); + } + } + + /// Validates the [AccessToken] of the provided [Credentials]. + /// + /// If none provided, checks the current [credentials]. + Future validateToken([Credentials? creds]) async { + if (creds == null) { + Log.debug( + 'validateToken($creds) with current being: ${credentials.value}', + '$runtimeType', + ); + } else { + Log.debug('validateToken($creds)', '$runtimeType'); + } return await WebUtils.protect(() async { + // If [creds] are not provided, then validate the current [credentials]. + creds ??= credentials.value; + + if (creds == null) { + return false; + } + try { - await _authRepository.validateToken(); + await _authRepository.validateToken(creds!); return true; } on AuthorizationException { return false; @@ -547,67 +661,114 @@ class AuthService extends GetxService { }); } - /// Refreshes the current [credentials]. - Future refreshSession() async { + /// Refreshes [Credentials] of the account with the provided [userId] or of + /// the active one, if [userId] is not provided. + Future refreshSession({UserId? userId}) async { final FutureOr futureOrBool = WebUtils.isLocked; final bool isLocked = futureOrBool is bool ? futureOrBool : await futureOrBool; - Log.debug('refreshSession() with `isLocked`: $isLocked', '$runtimeType'); + userId ??= this.userId; + final bool areCurrent = userId == this.userId; + + Log.debug( + 'refreshSession($userId) with `isLocked`: $isLocked', + '$runtimeType', + ); try { - // Do not perform renew since some other task has already renewed it. But - // still wait for the lock to be sure that session was renewed when - // current `refreshSession()` call resolves. + // Wait for the lock to be released and check the [Credentials] again as + // some other task may have already refreshed them. await WebUtils.protect(() async { + final Credentials? oldCreds = accounts[userId]?.value; + if (isLocked) { Log.debug( - 'refreshSession(): acquired the lock, while it was locked, thus should proceed: $_shouldRefresh', + 'refreshSession($userId): acquired the lock, while it was locked, thus should proceed: ${_shouldRefresh(oldCreds)}', '$runtimeType', ); - if (!_shouldRefresh) { - // [Credentials] are successfully updated. + if (!_shouldRefresh(oldCreds)) { + // [Credentials] are fresh. return; } } else { Log.debug( - 'refreshSession(): acquired the lock, while it was unlocked', + 'refreshSession($userId): acquired the lock, while it was unlocked', '$runtimeType', ); } - // Fetch the fresh [WebUtils.credentials], if there are any. - if (WebUtils.credentials != null && - WebUtils.credentials?.access.secret != - credentials.value?.access.secret) { - _authorized(WebUtils.credentials!); - status.value = RxStatus.success(); + if (oldCreds == null) { + // These [Credentials] were removed while we've been waiting for the + // lock to be released. + if (areCurrent) { + router.go(_unauthorized()); + } return; } - if (credentials.value == null) { - router.go(_unauthorized()); - } else { - try { - final Credentials data = await _authRepository - .refreshSession(credentials.value!.refresh.secret); - _authorized(data); + // Fetch the fresh [Credentials] from browser's storage, if there are + // any. + final Credentials? stored = WebUtils.getCredentials(oldCreds.userId); + + if (stored != null && stored.access.secret != oldCreds.access.secret) { + if (areCurrent) { + _authorized(stored); status.value = RxStatus.success(); - } on RefreshSessionException catch (_) { + } else { + // [Credentials] of another account were refreshed. + _putCredentials(stored); + } + return; + } + + try { + final Credentials data = await _authRepository.refreshSession( + oldCreds.refresh.secret, + reconnect: areCurrent, + ); + + if (areCurrent) { + _authorized(data); + } else { + // [Credentials] of not currently active account were updated, + // just save them. + // + // Saving to [Hive] is safe here, as this callback is guarded by + // the [WebUtils.protect] lock. + await _credentialsProvider.put(data); + _putCredentials(data); + } + status.value = RxStatus.success(); + } on RefreshSessionException catch (_) { + Log.debug( + 'refreshSession($userId): `RefreshSessionException` occurred, removing credentials', + '$runtimeType', + ); + + if (areCurrent) { router.go(_unauthorized()); - rethrow; + } else { + // Remove stale [Credentials]. + _credentialsProvider.remove(oldCreds.userId); + accounts.remove(oldCreds.userId); } + + rethrow; } }); } on RefreshSessionException catch (_) { - // No-op, already handled in the [WebUtils.protect]. + // No-op, already handled in the callback passed to [WebUtils.protect]. } catch (e) { - Log.debug('refreshSession(): Exception occurred: $e', '$runtimeType'); + Log.debug( + 'refreshSession($userId): Exception occurred: $e', + '$runtimeType', + ); // If any unexpected exception happens, just retry the mutation. await Future.delayed(const Duration(seconds: 2)); - await refreshSession(); + await refreshSession(userId: userId); } } @@ -624,6 +785,49 @@ class AuthService extends GetxService { await _authRepository.updateSessions(); } + /// Puts the provided [creds] to [accounts]. + void _putCredentials(Credentials creds) { + Log.debug('_putCredentials($creds)', '$runtimeType'); + + final Rx? stored = accounts[creds.userId]; + if (stored == null) { + accounts[creds.userId] = Rx(creds); + } else { + stored.value = creds; + } + } + + /// Initializes the refresh timers for all the authenticated [MyUser]s. + void _initRefreshTimers() { + Log.debug('_initRefreshTimers()', '$runtimeType'); + + _refreshTimers.forEach((_, t) => t.cancel()); + _refreshTimers.clear(); + + for (final UserId id in accounts.keys) { + _refreshTimers[id] = Timer.periodic( + _refreshTaskInterval, + (_) async { + final Credentials? creds = accounts[id]?.value; + if (creds == null) { + Log.debug( + '_initRefreshTimers(): no credentials found for user $id, killing timer', + '$runtimeType', + ); + + // Cancel the timer to avoid memory leaks. + _refreshTimers.remove(id)?.cancel(); + return; + } + + if (_shouldRefresh(creds)) { + await refreshSession(userId: id); + } + }, + ); + } + } + /// Sets authorized [status] to `isLoadingMore` (aka "partly authorized"). void _authorized(Credentials creds) { Log.debug('_authorized($creds)', '$runtimeType'); @@ -633,14 +837,9 @@ class AuthService extends GetxService { _authRepository.token = creds.access.secret; credentials.value = creds; - _refreshTimer?.cancel(); + _putCredentials(creds); - // TODO: Offload refresh task to the background process? - _refreshTimer = Timer.periodic(_refreshTaskInterval, (timer) { - if (credentials.value?.refresh != null && _shouldRefresh) { - refreshSession(); - } - }); + _initRefreshTimers(); status.value = RxStatus.loadingMore(); } @@ -649,18 +848,39 @@ class AuthService extends GetxService { String _unauthorized() { Log.debug('_unauthorized()', '$runtimeType'); - final UserId? id = _accountProvider.userId; + final UserId? id = userId; if (id != null) { _credentialsProvider.remove(id); + _refreshTimers.remove(id)?.cancel(); + accounts.remove(id); + } + + if (id == _accountProvider.userId) { + // This workarounds the situation when another tab on Web has already + // rewritten the value in [_accountProvider] during switching to another + // account but the tab this code is running on still uses the + // [credentials] of an old one, which is an expected behavior. + _accountProvider.clear(); } - _accountProvider.clear(); _authRepository.token = null; _authRepository.sessions.clear(); credentials.value = null; status.value = RxStatus.empty(); - _refreshTimer?.cancel(); return Routes.auth; } + + /// Indicates whether the [credentials] require a refresh. + /// + /// If [credentials] aren't provided, then ones of the current session are + /// checked. + bool _shouldRefresh([Credentials? credentials]) { + final Credentials? creds = credentials ?? this.credentials.value; + + return creds?.access.expireAt + .subtract(_accessTokenMinTtl) + .isBefore(PreciseDateTime.now().toUtc()) ?? + false; + } } diff --git a/lib/domain/service/my_user.dart b/lib/domain/service/my_user.dart index f66232428dd..2b1930b19c4 100644 --- a/lib/domain/service/my_user.dart +++ b/lib/domain/service/my_user.dart @@ -29,6 +29,7 @@ import '/domain/model/user.dart'; import '/domain/repository/my_user.dart'; import '/routes.dart'; import '/util/log.dart'; +import '/util/obs/rxmap.dart'; import '/util/web/web_utils.dart'; import 'auth.dart'; import 'disposable_service.dart'; @@ -50,6 +51,9 @@ class MyUserService extends DisposableService { /// Returns the currently authenticated [MyUser]. Rx get myUser => _userRepo.myUser; + /// Returns a reactive map of all the known [MyUser] profiles. + RxObsMap> get profiles => _userRepo.profiles; + @override void onInit() { Log.debug('onInit()', '$runtimeType'); @@ -121,8 +125,13 @@ class MyUserService extends DisposableService { await WebUtils.protect(() async { await _userRepo.updateUserPassword(oldPassword, newPassword); - // TODO: Replace `force` with something more granular and correct. - await _auth.signIn(newPassword, num: myUser.value?.num, force: true); + // TODO: Replace `unsafe` with something more granular and correct. + await _auth.signIn( + newPassword, + num: myUser.value?.num, + unsafe: true, + force: true, + ); }); }); } diff --git a/lib/provider/gql/base.dart b/lib/provider/gql/base.dart index e73e7cdc711..7432d2b1d97 100644 --- a/lib/provider/gql/base.dart +++ b/lib/provider/gql/base.dart @@ -129,12 +129,12 @@ class GraphQlClient { /// [RateLimiter] limiting the [subscribe] requests to the backend per second. final RateLimiter _subscriptionLimiter = RateLimiter( - per: const Duration(milliseconds: 500), + per: const Duration(milliseconds: 1000), ); /// [RateLimiter] limiting the [query] requests to the backend per second. final RateLimiter _queryLimiter = RateLimiter( - per: const Duration(milliseconds: 500), + per: const Duration(milliseconds: 1000), ); /// Returns [GraphQLClient] with or without [token] header authorization. diff --git a/lib/provider/gql/components/auth.dart b/lib/provider/gql/components/auth.dart index 77181af6f45..8f10aa69469 100644 --- a/lib/provider/gql/components/auth.dart +++ b/lib/provider/gql/components/auth.dart @@ -76,8 +76,17 @@ mixin AuthGraphQlMixin { /// ### Idempotent /// /// Succeeds as no-op if the specified [Session] has been deleted already. - Future deleteSession({SessionId? id, UserPassword? password}) async { - Log.debug('deleteSession($id, password)', '$runtimeType'); + Future deleteSession({ + SessionId? id, + UserPassword? password, + AccessTokenSecret? token, + }) async { + token ??= this.token; + + Log.debug( + 'deleteSession(id: $id, password: ${password?.obscured}, token: $token)', + '$runtimeType', + ); if (token != null) { final variables = DeleteSessionArguments(id: id, password: password); @@ -144,20 +153,20 @@ mixin AuthGraphQlMixin { as SignIn$Mutation$CreateSession$CreateSessionOk; } - /// Validates the current authorization token. + /// Validates the authorization token of the provided [Credentials]. /// /// ### Authentication /// /// Mandatory. - Future validateToken() async { - Log.debug('validateToken()', '$runtimeType'); + Future validateToken(Credentials creds) async { + Log.debug('validateToken($creds)', '$runtimeType'); final QueryResult res = await client.mutate( MutationOptions( operationName: 'ValidateToken', document: ValidateTokenQuery().document, ), - raw: RawClientOptions(token), + raw: RawClientOptions(creds.access.secret), ); return ValidateToken$Query.fromJson(res.data!); } diff --git a/lib/routes.dart b/lib/routes.dart index cbb05b50581..8c9cf35563c 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -68,6 +68,7 @@ import 'store/contact.dart'; import 'store/my_user.dart'; import 'store/settings.dart'; import 'store/user.dart'; +import 'themes.dart'; import 'ui/page/auth/view.dart'; import 'ui/page/chat_direct_link/view.dart'; import 'ui/page/erase/view.dart'; @@ -77,6 +78,7 @@ import 'ui/page/style/view.dart'; import 'ui/page/support/view.dart'; import 'ui/page/work/view.dart'; import 'ui/widget/lifecycle_observer.dart'; +import 'ui/widget/progress_indicator.dart'; import 'ui/worker/call.dart'; import 'ui/worker/chat.dart'; import 'ui/worker/my_user.dart'; @@ -104,6 +106,10 @@ class Routes { static const user = '/user'; static const work = '/work'; + // TODO: Dirty hack used to reinitialize the dependencies when changing + // accounts, should remove it. + static const nowhere = '/nowhere'; + // E2E tests related page, should not be used in non-test environment. static const restart = '/restart'; @@ -468,6 +474,17 @@ class AppRouterDelegate extends RouterDelegate child: Center(child: Text('Restarting...')), ), ]; + } else if (_state.route == Routes.nowhere) { + return [ + MaterialPage( + key: const ValueKey('NowherePage'), + name: Routes.nowhere, + child: Scaffold( + backgroundColor: Theme.of(router.context!).style.colors.background, + body: const Center(child: CustomProgressIndicator.big()), + ), + ), + ]; } else if (_state.route == Routes.style) { return [ const MaterialPage( @@ -959,6 +976,9 @@ extension RouteLinks on RouterState { /// /// If [push] is `true`, then location is pushed to the router location stack. void style({bool push = false}) => (push ? this.push : go)(Routes.style); + + /// Changes router location to the [Routes.nowhere] page. + void nowhere() => go(Routes.nowhere); } /// Extension adding helper methods to an [AppLifecycleState]. diff --git a/lib/store/auth.dart b/lib/store/auth.dart index 293a761f4b7..88a8ed69ddc 100644 --- a/lib/store/auth.dart +++ b/lib/store/auth.dart @@ -60,6 +60,11 @@ class AuthRepository implements AbstractAuthRepository { /// successful [confirmSignUpEmail]. Credentials? _signUpCredentials; + // TODO: Temporary solution, wait for support from backend. + /// [HiveMyUser] created with [signUpWithEmail] and put to [Hive] in + /// successful [confirmSignUpEmail]. + HiveMyUser? _signedUpUser; + @override set token(AccessTokenSecret? token) { Log.debug('set token($token)', '$runtimeType'); @@ -124,8 +129,7 @@ class AuthRepository implements AbstractAuthRepository { final response = await _graphQlProvider.signUp(); - _myUserProvider.put(response.createUser.user.toHive()); - + _signedUpUser = response.createUser.user.toHive(); _signUpCredentials = response.toModel(); await _graphQlProvider.addUserEmail( @@ -142,12 +146,17 @@ class AuthRepository implements AbstractAuthRepository { if (_signUpCredentials == null) { throw ArgumentError.notNull('_signUpCredentials'); + } else if (_signedUpUser == null) { + throw ArgumentError.notNull('_signedUpUser'); } await _graphQlProvider.confirmEmailCode( code, raw: RawClientOptions(_signUpCredentials!.access.secret), ); + + _myUserProvider.put(_signedUpUser!); + return _signUpCredentials!; } @@ -169,13 +178,22 @@ class AuthRepository implements AbstractAuthRepository { SessionId? id, UserPassword? password, FcmRegistrationToken? fcmToken, + AccessTokenSecret? accessToken, }) async { - Log.debug('deleteSession($fcmToken)', '$runtimeType'); + Log.debug( + 'deleteSession(id: $id, password: ${password?.obscured}, fcmToken: $fcmToken, accessToken: $accessToken)', + '$runtimeType', + ); if (fcmToken != null) { await _graphQlProvider.unregisterFcmDevice(fcmToken); } - await _graphQlProvider.deleteSession(id: id, password: password); + + await _graphQlProvider.deleteSession( + id: id, + password: password, + token: accessToken, + ); if (id != null) { sessions.removeWhere((e) => e.id == id); @@ -191,21 +209,28 @@ class AuthRepository implements AbstractAuthRepository { } @override - Future validateToken() async { - Log.debug('validateToken()', '$runtimeType'); - await _graphQlProvider.validateToken(); + Future validateToken(Credentials credentials) async { + Log.debug('validateToken($credentials)', '$runtimeType'); + await _graphQlProvider.validateToken(credentials); } @override - Future refreshSession(RefreshTokenSecret secret) { + Future refreshSession( + RefreshTokenSecret secret, { + bool reconnect = true, + }) { Log.debug('refreshSession($secret)', '$runtimeType'); return _graphQlProvider.clientGuard.protect(() async { final response = (await _graphQlProvider.refreshSession(secret)).refreshSession as RefreshSession$Mutation$RefreshSession$CreateSessionOk; - _graphQlProvider.token = response.accessToken.secret; - _graphQlProvider.reconnect(); + + if (reconnect) { + _graphQlProvider.token = response.accessToken.secret; + _graphQlProvider.reconnect(); + } + return response.toModel(); }); } diff --git a/lib/store/my_user.dart b/lib/store/my_user.dart index 30642c4b556..9f152cbd100 100644 --- a/lib/store/my_user.dart +++ b/lib/store/my_user.dart @@ -42,6 +42,7 @@ import '/provider/hive/my_user.dart'; import '/util/event_pool.dart'; import '/util/log.dart'; import '/util/new_type.dart'; +import '/util/obs/rxmap.dart'; import '/util/platform_utils.dart'; import '/util/stream_utils.dart'; import 'blocklist.dart'; @@ -62,6 +63,9 @@ class MyUserRepository implements AbstractMyUserRepository { @override late final Rx myUser; + @override + final RxObsMap> profiles = RxObsMap(); + /// Callback that is called when [MyUser] is deleted. late final void Function() onUserDeleted; @@ -125,6 +129,7 @@ class MyUserRepository implements AbstractMyUserRepository { myUser = Rx(_active?.value); + _initProfiles(); _initLocalSubscription(); _initRemoteSubscription(); @@ -622,6 +627,15 @@ class MyUserRepository implements AbstractMyUserRepository { } } + /// Populates the [profiles] with values stored in the [_myUserLocal]. + void _initProfiles() { + Log.debug('_initProfiles()', '$runtimeType'); + + for (final HiveMyUser u in _myUserLocal.valuesSafe) { + profiles[u.value.id] = Rx(u.value); + } + } + /// Initializes [MyUserHiveProvider.boxEvents] subscription. Future _initLocalSubscription() async { Log.debug('_initLocalSubscription()', '$runtimeType'); @@ -631,51 +645,72 @@ class MyUserRepository implements AbstractMyUserRepository { final BoxEvent event = _localSubscription!.current; final UserId id = UserId(event.key); - final MyUser? user = event.value?.value; + final bool isCurrent = id == (_active?.value ?? myUser.value)?.id; - if (id == (_active?.value ?? myUser.value)?.id) { - if (event.deleted) { + if (event.deleted) { + if (isCurrent) { myUser.value = null; _remoteSubscription?.close(immediate: true); - } else { + } + + profiles.remove(id); + } else { + if (event.value == null) { + Log.warning('Expected non-`null` value of `MyUser`', '$runtimeType'); + return; + } + + final MyUser user = event.value!.value; + + if (isCurrent) { // Copy [event.value], as it always contains the same [MyUser]. - final MyUser? value = user?.copyWith(); + final MyUser value = user.copyWith(); // Don't update the [MyUserField]s considered locked in the [_pool], as // those events might've been applied optimistically during mutations // and await corresponding subscription events to be persisted. - if (_pool.lockedWith(MyUserField.name, value?.name)) { - value?.name = myUser.value?.name; + if (_pool.lockedWith(MyUserField.name, value.name)) { + value.name = myUser.value?.name; } - if (_pool.lockedWith(MyUserField.status, value?.status)) { - value?.status = myUser.value?.status; + if (_pool.lockedWith(MyUserField.status, value.status)) { + value.status = myUser.value?.status; } - if (_pool.lockedWith(MyUserField.bio, value?.bio)) { - value?.bio = myUser.value?.bio; + if (_pool.lockedWith(MyUserField.bio, value.bio)) { + value.bio = myUser.value?.bio; } - if (_pool.lockedWith(MyUserField.presence, value?.presence)) { - value?.presence = myUser.value?.presence ?? value.presence; + if (_pool.lockedWith(MyUserField.presence, value.presence)) { + value.presence = myUser.value?.presence ?? value.presence; } - if (_pool.lockedWith(MyUserField.muted, value?.muted)) { - value?.muted = myUser.value?.muted; + if (_pool.lockedWith(MyUserField.muted, value.muted)) { + value.muted = myUser.value?.muted; } - if (_pool.lockedWith(MyUserField.email, value?.emails.unconfirmed)) { - value?.emails.unconfirmed = myUser.value?.emails.unconfirmed; + if (_pool.lockedWith(MyUserField.email, value.emails.unconfirmed)) { + value.emails.unconfirmed = myUser.value?.emails.unconfirmed; } - if (_pool.lockedWith(MyUserField.phone, value?.phones.unconfirmed)) { - value?.phones.unconfirmed = myUser.value?.phones.unconfirmed; + if (_pool.lockedWith(MyUserField.phone, value.phones.unconfirmed)) { + value.phones.unconfirmed = myUser.value?.phones.unconfirmed; } myUser.value = value; + profiles[id]?.value = value; + } + + // This event is not of the currently active [MyUser], so just update + // the [profiles]. + else { + final Rx? existing = profiles[id]; + if (existing == null) { + profiles[id] = Rx(user); + } else { + existing.value = user; + } } - } else { - // No-op, as those events aren't of the currently active [MyUser]. } } } diff --git a/lib/ui/page/home/page/user/controller.dart b/lib/ui/page/home/page/user/controller.dart index 25b8c036608..3a72f92dd5b 100644 --- a/lib/ui/page/home/page/user/controller.dart +++ b/lib/ui/page/home/page/user/controller.dart @@ -641,7 +641,7 @@ extension UserViewExt on User { /// Extension adding an ability to get text represented indication of how long /// ago a [DateTime] happened compared to [DateTime.now]. -extension _DateTimeToAgo on DateTime { +extension DateTimeToAgo on DateTime { /// Returns text representation of a [difference] with [DateTime.now] /// indicating how long ago this [DateTime] happened compared to /// [DateTime.now]. diff --git a/lib/ui/page/home/tab/menu/accounts/controller.dart b/lib/ui/page/home/tab/menu/accounts/controller.dart new file mode 100644 index 00000000000..ad039c8e584 --- /dev/null +++ b/lib/ui/page/home/tab/menu/accounts/controller.dart @@ -0,0 +1,503 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; + +import 'package:get/get.dart'; + +import '/api/backend/schema.dart' show ConfirmUserEmailErrorCode; +import '/domain/model/my_user.dart'; +import '/domain/model/session.dart'; +import '/domain/model/user.dart'; +import '/domain/service/auth.dart'; +import '/domain/service/my_user.dart'; +import '/l10n/l10n.dart'; +import '/provider/gql/exceptions.dart'; +import '/routes.dart'; +import '/ui/widget/text_field.dart'; +import '/util/message_popup.dart'; +import '/util/obs/obs.dart'; + +/// Possible [AccountsView] flow stage. +enum AccountsViewStage { + accounts, + add, + signUp, + signUpWithEmail, + signUpWithEmailCode, + signIn, + signInWithPassword, +} + +/// Controller of an [AccountsView]. +class AccountsController extends GetxController { + AccountsController( + this._myUserService, + this._authService, { + AccountsViewStage initial = AccountsViewStage.accounts, + }) : stage = Rx(initial); + + /// [AccountsViewStage] currently being displayed. + late final Rx stage; + + /// [MyUser.login]'s [TextFieldState]. + late final TextFieldState login; + + /// [TextFieldState] for a password input. + late final TextFieldState password; + + /// [TextFieldState] for an email input. + late final TextFieldState email; + + /// [TextFieldState] for an email code input. + late final TextFieldState emailCode; + + /// Indicator whether the [password] should be obscured. + final RxBool obscurePassword = RxBool(true); + + /// Amount of [signIn] unsuccessful submitting attempts. + int signInAttempts = 0; + + /// Amount of [emailCode] unsuccessful submitting attempts. + int codeAttempts = 0; + + /// Timeout of a [signIn] next invoke attempt. + final RxInt signInTimeout = RxInt(0); + + /// Timeout of a [emailCode] next submit attempt. + final RxInt codeTimeout = RxInt(0); + + /// Timeout of a [resendEmail] next invoke attempt. + final RxInt resendEmailTimeout = RxInt(0); + + /// Known [MyUser] accounts that can be used to [signIn] to. + final RxList> accounts = RxList(); + + /// [Timer] disabling [emailCode] submitting for [codeTimeout]. + Timer? _codeTimer; + + /// [Timer] disabling [signIn] invoking for [signInTimeout]. + Timer? _signInTimer; + + /// [Timer] used to disable resend code button for [resendEmailTimeout]. + Timer? _resendEmailTimer; + + /// [MyUserService] to obtain [accounts] and [me]. + final MyUserService _myUserService; + + /// [AuthService] providing the authentication capabilities. + final AuthService _authService; + + /// Subscription for [MyUserService.myUsers] changes updating the [accounts] + /// list. + StreamSubscription? _profilesSubscription; + + /// Returns [UserId] of currently authenticated [MyUser]. + UserId? get me => _authService.userId; + + /// Returns the current authentication status. + Rx get authStatus => _authService.status; + + /// Returns a reactive map of all the active [Credentials] for [accounts]. + /// + /// Accounts whose [UserId]s are present in this set are available for + /// switching. + RxMap> get sessions => _authService.accounts; + + /// Returns a reactive map of all the known [MyUser] profiles for [accounts]. + RxObsMap> get _profiles => _myUserService.profiles; + + @override + void onInit() { + for (var e in _profiles.values) { + accounts.add(e); + } + accounts.sort(_compareAccounts); + + _profilesSubscription = _profiles.changes.listen((e) async { + switch (e.op) { + case OperationKind.added: + accounts.add(e.value!); + accounts.sort(_compareAccounts); + break; + + case OperationKind.removed: + accounts.removeWhere((u) => u.value.id == e.key); + break; + + case OperationKind.updated: + accounts.sort(_compareAccounts); + break; + } + }); + + login = TextFieldState( + onFocus: (s) { + s.error.value = null; + password.unsubmit(); + }, + onSubmitted: (s) { + password.focus.requestFocus(); + s.unsubmit(); + }, + ); + + password = TextFieldState( + onFocus: (s) { + s.error.value = null; + s.unsubmit(); + }, + onSubmitted: (_) => signIn(), + ); + + email = TextFieldState( + onFocus: (s) { + try { + if (s.text.isNotEmpty) { + UserEmail(s.text.toLowerCase()); + } + + s.error.value = null; + } on FormatException { + s.error.value = 'err_incorrect_email'.l10n; + } + }, + onSubmitted: (s) async { + if (s.error.value != null) { + return; + } + + final UserEmail? email = UserEmail.tryParse(s.text.toLowerCase()); + + if (email == null) { + s.error.value = 'err_incorrect_email'.l10n; + } else { + emailCode.clear(); + stage.value = AccountsViewStage.signUpWithEmailCode; + try { + await _authService.signUpWithEmail(email); + s.unsubmit(); + } on AddUserEmailException catch (e) { + s.error.value = e.toMessage(); + _setResendEmailTimer(false); + + stage.value = AccountsViewStage.signUpWithEmail; + } catch (_) { + s.resubmitOnError.value = true; + s.error.value = 'err_data_transfer'.l10n; + _setResendEmailTimer(false); + s.unsubmit(); + + stage.value = AccountsViewStage.signUpWithEmail; + rethrow; + } + } + }, + ); + + emailCode = TextFieldState( + onSubmitted: (s) async { + s.status.value = RxStatus.loading(); + try { + await _authService.confirmSignUpEmail( + ConfirmationCode(emailCode.text), + force: true, + ); + + // TODO: This is a hack that should be removed, as whenever the + // account is changed, the [HomeView] and its dependencies must + // be rebuilt, which may take some unidentifiable amount of time + // as of now. + router.go(Routes.nowhere); + await Future.delayed(const Duration(milliseconds: 500)); + router.home(); + } on ConfirmUserEmailException catch (e) { + switch (e.code) { + case ConfirmUserEmailErrorCode.wrongCode: + s.error.value = e.toMessage(); + + ++codeAttempts; + if (codeAttempts >= 3) { + codeAttempts = 0; + _setCodeTimer(); + } + s.status.value = RxStatus.empty(); + break; + + default: + s.error.value = e.toMessage(); + break; + } + } on FormatException catch (_) { + s.error.value = 'err_wrong_recovery_code'.l10n; + s.status.value = RxStatus.empty(); + ++codeAttempts; + if (codeAttempts >= 3) { + codeAttempts = 0; + _setCodeTimer(); + } + } catch (_) { + s.resubmitOnError.value = true; + s.error.value = 'err_data_transfer'.l10n; + s.status.value = RxStatus.empty(); + s.unsubmit(); + rethrow; + } + }, + ); + + super.onInit(); + } + + @override + void onClose() { + _profilesSubscription?.cancel(); + super.onClose(); + } + + /// Tries to sign in into a new account and to redirect to the [Routes.home] + /// page. + Future signIn() async { + final String input = login.text.toLowerCase(); + + final UserLogin? userLogin = UserLogin.tryParse(input); + final UserNum? userNum = UserNum.tryParse(input); + final UserEmail? userEmail = UserEmail.tryParse(input); + final UserPhone? userPhone = UserPhone.tryParse(input); + final UserPassword? userPassword = UserPassword.tryParse(password.text); + + login.error.value = null; + password.error.value = null; + + final bool noCredentials = userLogin == null && + userNum == null && + userEmail == null && + userPhone == null; + + if (noCredentials || userPassword == null) { + password.error.value = 'err_incorrect_login_or_password'.l10n; + password.unsubmit(); + return; + } + + try { + login.status.value = RxStatus.loading(); + password.status.value = RxStatus.loading(); + + await _authService.signIn( + userPassword, + login: userLogin, + num: userNum, + email: userEmail, + phone: userPhone, + force: true, + ); + + // TODO: This is a hack that should be removed, as whenever the account is + // changed, the [HomeView] and its dependencies must be rebuilt, + // which may take some unidentifiable amount of time as of now. + router.nowhere(); + await Future.delayed(const Duration(milliseconds: 500)); + router.home(); + } on CreateSessionException catch (e) { + ++signInAttempts; + + if (signInAttempts >= 3) { + // Wrong password was entered three times. Login is possible in N + // seconds. + signInAttempts = 0; + _setSignInTimer(); + } + password.error.value = e.toMessage(); + } on ConnectionException { + password.unsubmit(); + password.error.value = 'err_data_transfer'.l10n; + password.resubmitOnError.value = true; + } catch (e) { + password.unsubmit(); + password.error.value = 'err_data_transfer'.l10n; + password.resubmitOnError.value = true; + rethrow; + } finally { + login.status.value = RxStatus.empty(); + password.status.value = RxStatus.empty(); + } + } + + /// Deletes the account with the provided [UserId] from the list. + /// + /// Also performs logout, when deleting the current account. + Future deleteAccount(UserId id) async { + accounts.removeWhere((e) => e.value.id == id); + + if (id == _authService.userId) { + _authService.logout(); + router.auth(); + router.tab = HomeTab.chats; + } else { + await _authService.removeAccount(id); + } + } + + /// Switches to the account with the given [id]. + Future switchTo(UserId id) async { + try { + // TODO: This is a hack that should be removed, as whenever the account is + // changed, the [HomeView] and its dependencies must be rebuilt, + // which may take some unidentifiable amount of time as of now. + router.nowhere(); + + final bool succeeded = await _authService.switchAccount(id); + if (succeeded) { + await Future.delayed(500.milliseconds); + router.tab = HomeTab.chats; + router.home(); + } else { + await Future.delayed(500.milliseconds); + router.home(); + await Future.delayed(500.milliseconds); + MessagePopup.error('err_account_unavailable'.l10n); + } + } catch (e) { + await Future.delayed(500.milliseconds); + router.home(); + await Future.delayed(500.milliseconds); + MessagePopup.error(e); + } + } + + /// Creates a new account and switches to it. + Future register() async { + router.nowhere(); + + try { + await _authService.register(force: true); + + // TODO: This is a hack that should be removed, as whenever the account is + // changed, the [HomeView] and its dependencies must be rebuilt, + // which may take some unidentifiable amount of time as of now. + await Future.delayed(500.milliseconds); + + router.tab = HomeTab.chats; + router.home(); + } catch (e) { + await Future.delayed(500.milliseconds); + router.home(); + await Future.delayed(500.milliseconds); + MessagePopup.error(e); + } + } + + /// Starts or stops the [_signInTimer] based on [enabled] value. + void _setSignInTimer([bool enabled = true]) { + if (enabled) { + signInTimeout.value = 30; + _signInTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + signInTimeout.value--; + if (signInTimeout.value <= 0) { + signInTimeout.value = 0; + _signInTimer?.cancel(); + _signInTimer = null; + } + }, + ); + } else { + signInTimeout.value = 0; + _signInTimer?.cancel(); + _signInTimer = null; + } + } + + /// Starts or stops the [_codeTimer] based on [enabled] value. + void _setCodeTimer([bool enabled = true]) { + if (enabled) { + codeTimeout.value = 30; + _codeTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + codeTimeout.value--; + if (codeTimeout.value <= 0) { + codeTimeout.value = 0; + _codeTimer?.cancel(); + _codeTimer = null; + } + }, + ); + } else { + codeTimeout.value = 0; + _codeTimer?.cancel(); + _codeTimer = null; + } + } + + /// Starts or stops the [_resendEmailTimer] based on [enabled] value. + void _setResendEmailTimer([bool enabled = true]) { + if (enabled) { + resendEmailTimeout.value = 30; + _resendEmailTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + resendEmailTimeout.value--; + if (resendEmailTimeout.value <= 0) { + resendEmailTimeout.value = 0; + _resendEmailTimer?.cancel(); + _resendEmailTimer = null; + } + }, + ); + } else { + resendEmailTimeout.value = 0; + _resendEmailTimer?.cancel(); + _resendEmailTimer = null; + } + } + + /// Resends a [ConfirmationCode] to the specified [email]. + Future resendEmail() async { + _setResendEmailTimer(); + + try { + await _authService.resendSignUpEmail(); + } on ResendUserEmailConfirmationException catch (e) { + emailCode.error.value = e.toMessage(); + } catch (e) { + emailCode.error.value = 'err_data_transfer'.l10n; + _setResendEmailTimer(false); + rethrow; + } + } + + /// Compares two [MyUser]s based on their last seen times and the online + /// statuses. + int _compareAccounts(Rx a, Rx b) { + if (a.value.id == me) { + return -1; + } else if (b.value.id == me) { + return 1; + } else if (a.value.online && !b.value.online) { + return -1; + } else if (!a.value.online && b.value.online) { + return 1; + } else if (a.value.lastSeenAt == null || b.value.lastSeenAt == null) { + return -1; + } + + return -a.value.lastSeenAt!.compareTo(b.value.lastSeenAt!); + } +} diff --git a/lib/ui/page/home/tab/menu/accounts/view.dart b/lib/ui/page/home/tab/menu/accounts/view.dart new file mode 100644 index 00000000000..ac8da34452e --- /dev/null +++ b/lib/ui/page/home/tab/menu/accounts/view.dart @@ -0,0 +1,508 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; + +import 'package:animated_size_and_fade/animated_size_and_fade.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '/domain/model/my_user.dart'; +import '/l10n/l10n.dart'; +import '/themes.dart'; +import '/ui/page/home/page/chat/widget/chat_item.dart'; +import '/ui/page/home/widget/avatar.dart'; +import '/ui/page/home/widget/contact_tile.dart'; +import '/ui/page/login/widget/sign_button.dart'; +import '/ui/widget/animated_button.dart'; +import '/ui/widget/modal_popup.dart'; +import '/ui/widget/outlined_rounded_button.dart'; +import '/ui/widget/primary_button.dart'; +import '/ui/widget/svg/svg.dart'; +import '/ui/widget/text_field.dart'; +import '/ui/widget/widget_button.dart'; +import '/util/message_popup.dart'; +import 'controller.dart'; + +/// View for known [MyUser] profiles management. +/// +/// Intended to be displayed with the [show] method. +class AccountsView extends StatelessWidget { + const AccountsView({super.key, this.initial = AccountsViewStage.accounts}); + + /// Initial [AccountsViewStage] of this [AccountsView]. + final AccountsViewStage initial; + + /// Displays an [AccountsView] wrapped in a [ModalPopup]. + static Future show( + BuildContext context, { + AccountsViewStage initial = AccountsViewStage.accounts, + }) { + return ModalPopup.show( + context: context, + background: Theme.of(context).style.colors.background, + child: AccountsView(initial: initial), + ); + } + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).style; + + return GetBuilder( + key: const Key('AccountsView'), + init: AccountsController(Get.find(), Get.find()), + builder: (AccountsController c) { + return Obx(() { + final Widget header; + final List children; + final List bottom = []; + + switch (c.stage.value) { + case AccountsViewStage.signInWithPassword: + header = ModalPopupHeader( + text: 'label_sign_in_with_password'.l10n, + onBack: () => c.stage.value = AccountsViewStage.signIn, + ); + + children = [ + const SizedBox(height: 12), + ReactiveTextField( + key: const Key('UsernameField'), + state: c.login, + label: 'label_sign_in_input'.l10n, + ), + const SizedBox(height: 16), + ReactiveTextField( + key: const ValueKey('PasswordField'), + state: c.password, + label: 'label_password'.l10n, + obscure: c.obscurePassword.value, + onSuffixPressed: c.obscurePassword.toggle, + treatErrorAsStatus: false, + trailing: SvgIcon( + c.obscurePassword.value + ? SvgIcons.visibleOff + : SvgIcons.visibleOn, + ), + ), + const SizedBox(height: 25), + Obx(() { + final bool enabled = !c.login.isEmpty.value && + !c.password.isEmpty.value && + c.signInTimeout.value == 0 && + !c.password.status.value.isLoading; + + return PrimaryButton( + key: const Key('LoginButton'), + title: c.signInTimeout.value == 0 + ? 'btn_sign_in'.l10n + : 'label_wait_seconds' + .l10nfmt({'for': c.signInTimeout.value}), + onPressed: enabled ? c.password.submit : null, + ); + }), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.signIn: + header = ModalPopupHeader( + text: 'label_sign_in'.l10n, + onBack: () => c.stage.value = AccountsViewStage.add, + ); + + children = [ + SignButton( + title: 'btn_password'.l10n, + onPressed: () => + c.stage.value = AccountsViewStage.signInWithPassword, + icon: const SvgIcon(SvgIcons.password), + padding: const EdgeInsets.only(left: 1), + ), + const SizedBox(height: 25 / 2), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.signUpWithEmailCode: + header = ModalPopupHeader( + text: 'label_sign_up'.l10n, + onBack: () => c.stage.value = AccountsViewStage.add, + ); + children = [ + Text.rich( + 'label_sign_up_code_email_sent' + .l10nfmt({'text': c.email.text}).parseLinks( + [], + style.fonts.medium.regular.primary, + ), + style: style.fonts.medium.regular.onBackground, + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Obx(() { + return Text( + c.resendEmailTimeout.value == 0 + ? 'label_did_not_receive_code'.l10n + : 'label_code_sent_again'.l10n, + style: style.fonts.medium.regular.onBackground, + ); + }), + ), + Align( + alignment: Alignment.centerLeft, + child: Obx(() { + final bool enabled = c.resendEmailTimeout.value == 0; + + return WidgetButton( + onPressed: enabled ? c.resendEmail : null, + child: Text( + enabled + ? 'btn_resend_code'.l10n + : 'label_wait_seconds' + .l10nfmt({'for': c.resendEmailTimeout.value}), + style: enabled + ? style.fonts.medium.regular.primary + : style.fonts.medium.regular.onBackground, + ), + ); + }), + ), + const SizedBox(height: 25), + ReactiveTextField( + key: const Key('EmailCodeField'), + state: c.emailCode, + label: 'label_confirmation_code'.l10n, + type: TextInputType.number, + ), + const SizedBox(height: 25), + Obx(() { + final bool enabled = !c.emailCode.isEmpty.value && + c.codeTimeout.value == 0 && + !c.emailCode.status.value.isLoading; + + return PrimaryButton( + key: const Key('Proceed'), + title: c.codeTimeout.value == 0 + ? 'btn_send'.l10n + : 'label_wait_seconds' + .l10nfmt({'for': c.codeTimeout.value}), + onPressed: enabled ? c.emailCode.submit : null, + ); + }), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.signUpWithEmail: + header = ModalPopupHeader( + text: 'label_sign_up'.l10n, + onBack: () { + c.stage.value = AccountsViewStage.signUp; + c.email.unsubmit(); + }, + ); + children = [ + ReactiveTextField( + state: c.email, + label: 'label_email'.l10n, + hint: 'example@domain.com', + style: style.fonts.normal.regular.onBackground, + treatErrorAsStatus: false, + ), + const SizedBox(height: 25), + Center( + child: Obx(() { + final bool enabled = !c.email.isEmpty.value; + + return OutlinedRoundedButton( + onPressed: enabled ? c.email.submit : null, + color: style.colors.primary, + maxWidth: double.infinity, + child: Text( + 'btn_proceed'.l10n, + style: enabled + ? style.fonts.medium.regular.onPrimary + : style.fonts.medium.regular.onBackground, + ), + ); + }), + ), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.signUp: + header = ModalPopupHeader( + text: 'label_sign_up'.l10n, + onBack: () => c.stage.value = AccountsViewStage.add, + ); + children = [ + SignButton( + title: 'btn_email'.l10n, + icon: const SvgIcon(SvgIcons.email), + onPressed: () => + c.stage.value = AccountsViewStage.signUpWithEmail, + ), + const SizedBox(height: 25 / 2), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.add: + header = ModalPopupHeader( + text: 'label_add_account'.l10n, + onBack: () => c.stage.value = AccountsViewStage.accounts, + ); + + children = [ + Center( + child: Obx(() { + final bool enabled = c.authStatus.value.isSuccess; + + return OutlinedRoundedButton( + key: const Key('StartButton'), + maxWidth: 290, + height: 46, + leading: Transform.translate( + offset: const Offset(4, 0), + child: const SvgIcon(SvgIcons.guest), + ), + onPressed: enabled + ? () { + Navigator.of(context).pop(); + c.register(); + } + : () {}, + child: Text('btn_guest'.l10n), + ); + }), + ), + const SizedBox(height: 15), + Center( + child: OutlinedRoundedButton( + key: const Key('SignUpButton'), + maxWidth: 290, + height: 46, + leading: Transform.translate( + offset: const Offset(3, 0), + child: const SvgIcon(SvgIcons.register), + ), + onPressed: () => c.stage.value = AccountsViewStage.signUp, + child: Text('btn_sign_up'.l10n), + ), + ), + const SizedBox(height: 15), + Center( + child: OutlinedRoundedButton( + key: const Key('SignInButton'), + maxWidth: 290, + height: 46, + leading: Transform.translate( + offset: const Offset(4, 0), + child: const SvgIcon(SvgIcons.enter), + ), + onPressed: () => c.stage.value = AccountsViewStage.signIn, + child: Text('btn_sign_in'.l10n), + ), + ), + const SizedBox(height: 16), + ]; + break; + + case AccountsViewStage.accounts: + header = ModalPopupHeader(text: 'label_accounts'.l10n); + children = []; + + for (final e in c.accounts) { + children.add( + Obx(() { + final MyUser myUser = e.value; + final bool authorized = c.sessions.containsKey(myUser.id); + final bool active = c.me == myUser.id; + + return ContactTile( + myUser: myUser, + darken: authorized ? 0 : 0.06, + + // TODO: Prompt to sign in to the non-[authorized]. + onTap: authorized && c.me != myUser.id + ? () { + Navigator.of(context).pop(); + c.switchTo(myUser.id); + } + : null, + + // TODO: Remove, when [MyUser]s will receive their + // updates in real-time. + avatarBuilder: (_) => AvatarWidget.fromMyUser( + myUser, + radius: AvatarRadius.large, + badge: active, + ), + + trailing: [ + AnimatedButton( + decorator: (child) => Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 6, 8), + child: child, + ), + onPressed: () async { + final bool? result = await MessagePopup.alert( + 'btn_logout'.l10n, + description: [ + TextSpan( + style: style.fonts.medium.regular.secondary, + children: [ + TextSpan( + text: + 'alert_are_you_sure_want_to_log_out1' + .l10n, + ), + TextSpan( + style: style + .fonts.medium.regular.onBackground, + text: '${myUser.name ?? myUser.num}', + ), + TextSpan( + text: + 'alert_are_you_sure_want_to_log_out2' + .l10n, + ), + if (!myUser.hasPassword) ...[ + const TextSpan(text: '\n\n'), + TextSpan( + text: 'label_password_not_set'.l10n, + ), + ], + if (myUser.emails.confirmed.isEmpty && + myUser.phones.confirmed.isEmpty) ...[ + const TextSpan(text: '\n\n'), + TextSpan( + text: + 'label_email_or_phone_not_set'.l10n, + ), + ], + ], + ) + ], + ); + + if (result == true) { + await c.deleteAccount(myUser.id); + } + }, + child: active + ? const SvgIcon(SvgIcons.logoutWhite) + : const SvgIcon(SvgIcons.logout), + ), + ], + selected: active, + subtitle: [ + const SizedBox(height: 5), + if (active) + Text( + 'label_active_account'.l10n, + style: style.fonts.small.regular.onPrimary, + ) + + // TODO: Uncomment, when [MyUser]s will receive their + // updates in real-time. + // else + // Text( + // myUser.getStatus() ?? '', + // style: style.fonts.small.regular.secondary, + // ) + ], + ); + }), + ); + } + + children.add(const SizedBox(height: 10)); + + bottom.addAll([ + Padding( + padding: ModalPopup.padding(context), + child: PrimaryButton( + onPressed: () => c.stage.value = AccountsViewStage.add, + title: 'btn_add_account'.l10n, + ), + ), + const SizedBox(height: 16), + ]); + break; + } + + return AnimatedSizeAndFade( + fadeDuration: const Duration(milliseconds: 250), + sizeDuration: const Duration(milliseconds: 250), + child: Column( + key: Key('${c.stage.value.name.capitalizeFirst}Stage'), + mainAxisSize: MainAxisSize.min, + children: [ + header, + const SizedBox(height: 13), + Flexible( + child: ListView( + padding: ModalPopup.padding(context), + shrinkWrap: true, + children: children, + ), + ), + ...bottom, + ], + ), + ); + }); + }, + ); + } +} + +// TODO: Uncomment, when [MyUser]s will receive their updates in real-time. +/// Extension adding [MyUser] related wrappers and helpers. +// extension _MyUserViewExt on MyUser { +// /// Returns a text represented status of this [MyUser] based on its +// /// [MyUser.presence] and [MyUser.online] fields. +// String? getStatus() { +// switch (presence) { +// case Presence.present: +// if (online) { +// return 'label_online'.l10n; +// } else if (lastSeenAt != null) { +// return lastSeenAt?.val.toDifferenceAgo(); +// } else { +// return 'label_offline'.l10n; +// } + +// case Presence.away: +// if (online) { +// return 'label_away'.l10n; +// } else if (lastSeenAt != null) { +// return lastSeenAt?.val.toDifferenceAgo(); +// } else { +// return 'label_offline'.l10n; +// } + +// case Presence.artemisUnknown: +// return null; +// } +// } +// } diff --git a/lib/ui/page/home/tab/menu/controller.dart b/lib/ui/page/home/tab/menu/controller.dart index ae3e72041a0..72a733ecf0c 100644 --- a/lib/ui/page/home/tab/menu/controller.dart +++ b/lib/ui/page/home/tab/menu/controller.dart @@ -22,9 +22,11 @@ import 'package:get/get.dart'; import '/api/backend/schema.dart' show Presence; import '/domain/model/my_user.dart'; +import '/domain/model/user.dart'; import '/domain/service/auth.dart'; import '/domain/service/my_user.dart'; import '/routes.dart'; +import '/util/obs/rxmap.dart'; import 'confirm/view.dart'; export 'view.dart'; @@ -45,9 +47,12 @@ class MenuTabController extends GetxController { /// Service managing [MyUser]. final MyUserService _myUserService; - /// Current [MyUser]. + /// Returns the current [MyUser]. Rx get myUser => _myUserService.myUser; + /// Returns the known [MyUser] profiles. + RxObsMap> get profiles => _myUserService.profiles; + @override void onClose() { scrollController.dispose(); diff --git a/lib/ui/page/home/tab/menu/view.dart b/lib/ui/page/home/tab/menu/view.dart index c3fa26c498c..bc05505c374 100644 --- a/lib/ui/page/home/tab/menu/view.dart +++ b/lib/ui/page/home/tab/menu/view.dart @@ -28,7 +28,9 @@ import '/ui/page/home/widget/safe_scrollbar.dart'; import '/ui/widget/context_menu/menu.dart'; import '/ui/widget/context_menu/region.dart'; import '/ui/widget/menu_button.dart'; +import '/ui/widget/widget_button.dart'; import '/util/platform_utils.dart'; +import 'accounts/view.dart'; import 'controller.dart'; /// View of the [HomeTab.menu] tab. @@ -114,6 +116,27 @@ class MenuTabView extends StatelessWidget { ), ), leading: const [SizedBox(width: 20)], + actions: [ + WidgetButton( + behavior: HitTestBehavior.translucent, + onPressed: () => AccountsView.show(context), + child: Padding( + padding: const EdgeInsets.only(right: 16), + child: Obx(() { + final bool hasMultipleAccounts = c.profiles.length > 1; + final String label = hasMultipleAccounts + ? 'btn_change_account_desc' + : 'btn_add_account_with_desc'; + + return Text( + label.l10n, + style: style.fonts.small.regular.primary, + textAlign: TextAlign.center, + ); + }), + ), + ), + ], ), body: SafeScrollbar( controller: c.scrollController, diff --git a/lib/ui/page/popup_call/controller.dart b/lib/ui/page/popup_call/controller.dart index b28e7ba5cdf..150b4505b48 100644 --- a/lib/ui/page/popup_call/controller.dart +++ b/lib/ui/page/popup_call/controller.dart @@ -58,7 +58,7 @@ class PopupCallController extends GetxController { @override void onInit() { WebStoredCall? stored = WebUtils.getCall(chatId); - if (stored == null || WebUtils.credentials == null) { + if (stored == null || WebUtils.getCredentials(me) == null) { return WebUtils.closeWindow(); } @@ -88,7 +88,8 @@ class PopupCallController extends GetxController { if (e.key == null) { WebUtils.closeWindow(); } else if (e.newValue == null) { - if (e.key == 'credentials' || e.key == 'call_${call.value.chatId}') { + if (e.key == 'credentials_$me' || + e.key == 'call_${call.value.chatId}') { WebUtils.closeWindow(); } } else if (e.key == 'call_${call.value.chatId}') { diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index 75001351002..3ee0f7d9626 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1893,6 +1893,18 @@ class SvgIcons { height: 32, ); + static const SvgData logout = SvgData( + 'assets/icons/logout.svg', + width: 16.28, + height: 19, + ); + + static const SvgData logoutWhite = SvgData( + 'assets/icons/logout_white.svg', + width: 16.28, + height: 19, + ); + static const SvgData addParticipant = SvgData( 'assets/icons/add_participant.svg', width: 21.48, diff --git a/lib/util/log.dart b/lib/util/log.dart index 4e300aeac80..01dac0fe391 100644 --- a/lib/util/log.dart +++ b/lib/util/log.dart @@ -20,6 +20,7 @@ import 'package:log_me/log_me.dart' as me; import 'package:sentry_flutter/sentry_flutter.dart'; import '/config.dart'; +import 'new_type.dart'; /// Utility logging messages to console. class Log { @@ -80,3 +81,16 @@ class Log { } } } + +/// Extension adding obscured getter to [NewType]s. +extension ObscuredNewTypeExtension on NewType { + /// Returns this value as a obscured string. + /// + /// Intended to be used to obscure sensitive information in [Log]s: + /// ```dart + /// // - prints `[Type] signIn(password: ***)`, when non-`null`; + /// // - prints `[Type] signIn(password: null)`, when `null`; + /// Log.debug('signIn(password: ${password?.obscured})', '$runtimeType'); + /// ``` + String get obscured => '***'; +} diff --git a/lib/util/web/non_web.dart b/lib/util/web/non_web.dart index e7658c1c67d..12bca4d0b8d 100644 --- a/lib/util/web/non_web.dart +++ b/lib/util/web/non_web.dart @@ -33,6 +33,7 @@ import 'package:win32/win32.dart'; import '/config.dart'; import '/domain/model/chat.dart'; import '/domain/model/session.dart'; +import '/domain/model/user.dart'; import '/routes.dart'; import '/util/ios_utils.dart'; import '/util/platform_utils.dart'; @@ -80,16 +81,23 @@ class WebUtils { /// Indicates whether the current window is a popup. static bool get isPopup => false; - /// Sets the provided [Credentials] to the browser's storage. - static set credentials(Credentials? creds) { + /// Indicates whether the [protect] is currently locked. + static FutureOr get isLocked => _guard.isLocked; + + /// Removes [Credentials] identified by the provided [UserId] from the + /// browser's storage. + static void removeCredentials(UserId userId) { // No-op. } - /// Returns the stored in browser's storage [Credentials]. - static Credentials? get credentials => null; + /// Puts the provided [Credentials] to the browser's storage. + static void putCredentials(Credentials creds) { + // No-op. + } - /// Indicates whether the [protect] is currently locked. - static FutureOr get isLocked => _guard.isLocked; + /// Returns the stored in browser's storage [Credentials] identified by the + /// provided [UserId], if any. + static Credentials? getCredentials(UserId userId) => null; /// Guarantees the [callback] is invoked synchronously, only by single tab or /// code block at the same time. diff --git a/lib/util/web/web.dart b/lib/util/web/web.dart index 12c9f18314e..91ff7c5ec94 100644 --- a/lib/util/web/web.dart +++ b/lib/util/web/web.dart @@ -40,11 +40,12 @@ import 'package:mutex/mutex.dart'; import 'package:platform_detect/platform_detect.dart'; import 'package:uuid/uuid.dart'; -import '../platform_utils.dart'; import '/config.dart'; import '/domain/model/chat.dart'; import '/domain/model/session.dart'; +import '/domain/model/user.dart'; import '/routes.dart'; +import '/util/platform_utils.dart'; import 'web_utils.dart'; html.Navigator _navigator = html.window.navigator; @@ -122,7 +123,7 @@ external Future _requestLock( ); @JS('getLocks') -external Future _queryLock(); +external Future _getLocks(); @JS('locksAvailable') external bool _locksAvailable(); @@ -291,25 +292,6 @@ class WebUtils { /// Indicates whether the current window is a popup. static bool get isPopup => _isPopup; - /// Sets the provided [Credentials] to the browser's storage. - static set credentials(Credentials? creds) { - if (creds == null) { - html.window.localStorage.remove('credentials'); - } else { - html.window.localStorage['credentials'] = json.encode(creds.toJson()); - } - } - - /// Returns the stored in browser's storage [Credentials]. - static Credentials? get credentials { - if (html.window.localStorage['credentials'] == null) { - return null; - } else { - var decoded = json.decode(html.window.localStorage['credentials']!); - return Credentials.fromJson(decoded); - } - } - /// Indicates whether the [protect] is currently locked. static FutureOr get isLocked async { // Web Locks API is unavailable for some reason, so proceed without it. @@ -320,7 +302,7 @@ class WebUtils { bool held = false; try { - final locks = await promiseToFuture(_queryLock()); + final locks = await promiseToFuture(_getLocks()); held = (locks as List?)?.any((e) => e.name == 'mutex') == true; } catch (e) { held = false; @@ -329,6 +311,31 @@ class WebUtils { return _guard.isLocked || held; } + /// Removes [Credentials] identified by the provided [UserId] from the + /// browser's storage. + static void removeCredentials(UserId userId) { + html.window.localStorage.remove('credentials_$userId'); + } + + /// Puts the provided [Credentials] to the browser's storage. + static void putCredentials(Credentials creds) { + html.window.localStorage['credentials_${creds.userId}'] = json.encode( + creds.toJson(), + ); + } + + /// Returns the stored in browser's storage [Credentials] identified by the + /// provided [UserId], if any. + static Credentials? getCredentials(UserId userId) { + if (html.window.localStorage['credentials_$userId'] == null) { + return null; + } else { + return Credentials.fromJson( + json.decode(html.window.localStorage['credentials_$userId']!), + ); + } + } + /// Guarantees the [callback] is invoked synchronously, only by single tab or /// code block at the same time. static Future protect(Future Function() callback) async { diff --git a/test/e2e/steps/users.dart b/test/e2e/steps/users.dart index 46b991e73b7..8160411c4f9 100644 --- a/test/e2e/steps/users.dart +++ b/test/e2e/steps/users.dart @@ -73,8 +73,12 @@ final StepDefinitionGeneric signInAs = then1( } catch (_) { var password = UserPassword('123'); - await Get.find() - .signIn(password, num: context.world.sessions[user.name]!.userNum); + await Get.find().signIn( + password, + num: context.world.sessions[user.name]!.userNum, + unsafe: true, + force: true, + ); } router.home(); From 2d76ab692e6cbaf5840d31610d036f4a7db7e4f0 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 9 May 2024 17:53:52 +0300 Subject: [PATCH 71/88] Try adding donation bot --- lib/store/chat_rx.dart | 51 +++++++++++++++------- lib/ui/page/home/page/chat/controller.dart | 23 ++++++++++ lib/ui/page/home/page/user/view.dart | 6 ++- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index 78009886b88..bcb73a04b19 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -1024,22 +1024,41 @@ class HiveRxChat extends RxChat { @override Future addBot(RxUser user, {bool first = true}) async { if (first) { - await postChatMessage( - text: ChatMessageText.bot( - localized: { - const Locale('en', 'US'): const ChatBotText( - title: 'Translation', - text: - 'Translation service is enabled. Certificated translators in real-time. [Terms and services](https://google.com)', - ), - const Locale('ru', 'RU'): const ChatBotText( - title: 'Перевод', - text: - 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени. [Условия использования](https://google.com)', - ), - }, - ), - ); + if (user.user.value.name?.val == 'Translation Service') { + await postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: + 'Translation service is enabled. Certificated translators in real-time. [Terms and services](https://google.com)', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: + 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени. [Условия использования](https://google.com)', + ), + }, + ), + ); + } else if (user.user.value.name?.val == 'Donation Service') { + await postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): const ChatBotText( + title: 'Translation', + text: + 'Donation service is enabled. [Terms and services](https://google.com)', + ), + const Locale('ru', 'RU'): const ChatBotText( + title: 'Перевод', + text: + 'Подключен сервис донатов. [Условия использования](https://google.com)', + ), + }, + ), + ); + } } bots.addIf(!bots.contains(user), user); diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index af38861ec3e..ea3195b48e9 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -1137,6 +1137,29 @@ class ChatController extends GetxController { repliesTo: [repliesTo], ); } + } else { + if (command.startsWith('/donate')) { + final double? sum = + double.tryParse(command.substring('/donate'.length)); + + if (sum != null) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): ChatBotText( + title: 'Donate', + text: 'Donated \$${sum.toStringAsFixed(2)}', + ), + const Locale('ru', 'RU'): ChatBotText( + title: 'Донат', + text: 'Отправлен донат \$${sum.toStringAsFixed(2)}', + ), + }, + ), + ); + } + } } } diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index 054ddd44102..e052ab11a09 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -293,7 +293,11 @@ class UserView extends StatelessWidget { ), if (bio != null) Paddings.basic( - InfoTile(title: 'label_about'.l10n, content: bio.val), + InfoTile( + title: 'label_about'.l10n, + content: bio.val, + maxLines: null, + ), ), ], ); From 8d665d65bddb49c66f6837a20ddffb9350c5b80d Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 10 May 2024 13:13:16 +0300 Subject: [PATCH 72/88] Fix --- lib/ui/page/login/view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ui/page/login/view.dart b/lib/ui/page/login/view.dart index 0955f54ecb2..44ca235ea84 100644 --- a/lib/ui/page/login/view.dart +++ b/lib/ui/page/login/view.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '/domain/model/my_user.dart'; +import '/domain/model/user.dart'; import '/l10n/l10n.dart'; import '/themes.dart'; import '/ui/page/home/page/chat/widget/chat_item.dart'; From e4a817a31eef4cff681ad8d005e3f82f10718b72 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 20 May 2024 15:21:27 +0300 Subject: [PATCH 73/88] Squashed commit of the following: commit 44d26c67f0510248fdb284a2056c25b0c76d2c6d Author: SleepySquash Date: Mon May 20 15:19:21 2024 +0300 Fix `dartdoc` commit 2b99463980055f42ec3a375c059230e5f7ac4d27 Author: SleepySquash Date: Mon May 20 14:57:40 2024 +0300 Corrections commit 00893bd7f7e8310a2e068dc0cde953ce14ec7fae Merge: f983a2333b 0aaa8dbeb8 Author: SleepySquash Date: Mon May 20 14:24:19 2024 +0300 Merge remote-tracking branch 'origin/main' into migrate-chat-items-to-drift commit f983a2333bcda50f6b81867365c59f719d44c1dd Author: SleepySquash Date: Mon May 20 14:24:14 2024 +0300 Fix `dartdoc` commit 4cee838c32467fdfba622e53ea73df7d38c6d8de Author: SleepySquash Date: Mon May 20 13:20:04 2024 +0300 Fix `Chat.firstItem` and `_paginateAround` commit c2fece950348ffb55f99d73dbdebc3bf24b2e3fe Author: SleepySquash Date: Mon May 20 11:30:55 2024 +0300 Fix local `ChatMessage`s commit 5448e7d9b8c4bb2fe2b9448459cedf859fee142a Author: SleepySquash Date: Mon May 20 10:41:20 2024 +0300 Fix stuff commit 22507fe62c6d63d472c4f44bfb6177d4cb21d072 Author: SleepySquash Date: Mon May 20 10:12:09 2024 +0300 Fix invalid `PageInfo` details commit 69e84a9afa0ccae9fc2cb7a95a9a2a5303f0c8de Author: SleepySquash Date: Mon May 20 09:56:25 2024 +0300 Fix widget test infinite hang up commit 51686f65db872b20309aa1797fe16f9e02a17431 Author: SleepySquash Date: Mon May 20 09:28:47 2024 +0300 Remove shadows from `Avatar`s commit 9baa55337f59c6ffccd9fdb38b5d29e026f65d82 Merge: b713d3d017 41d0cecc28 Author: SleepySquash Date: Fri May 17 21:13:42 2024 +0300 Merge remote-tracking branch 'origin/main' into migrate-chat-items-to-drift commit b713d3d017a2bc5fb6651c029e83e1611abd2016 Author: SleepySquash Date: Fri May 17 20:39:10 2024 +0300 Corrections commit 9e84966595bbe1d6eb173e0db90995330b2436fd Author: SleepySquash Date: Fri May 17 20:20:10 2024 +0300 Try fixing some bugs commit 61a33132b6569d2d6296608aa9a287dbca9a4438 Author: SleepySquash Date: Fri May 17 19:14:49 2024 +0300 Fix a lot of bugs commit e116a62e054c9afb4cbb0b0a14877263e4ccb938 Author: SleepySquash Date: Fri May 17 16:51:23 2024 +0300 Make copyright happy commit 5aad515a9a1cdc38f2d5af2e54b0334d90cc11f0 Author: SleepySquash Date: Fri May 17 16:50:52 2024 +0300 Fix unit and widget tests commit f5ee29e2c052f7ac20b941a1b12a71919dd75dea Author: SleepySquash Date: Fri May 17 16:15:54 2024 +0300 Fix `before` being `int` commit f8d85d5edcf277ac138d5a9d813d862e28770ac4 Author: SleepySquash Date: Fri May 17 15:22:35 2024 +0300 Improve everything commit 28b93673e59891cb404e52b7af981ec1a2b0b466 Author: SleepySquash Date: Fri May 17 09:37:20 2024 +0300 Fix `toJson` conversion failure commit f307019105c5a19896b4545e9b32333209d758db Author: SleepySquash Date: Thu May 16 19:34:17 2024 +0300 Impl drift provider for `Pagination` commit f2817855f57feee1913261c289b93784a1a89953 Author: SleepySquash Date: Thu May 16 18:40:46 2024 +0300 Fix errors commit 4842a2b5691dcd1a5e69d0a228671f55664735f0 Author: SleepySquash Date: Thu May 16 15:17:34 2024 +0300 Impl tables WIP commit 7c3df301344dcd39bea128899ee7d6358c40d1c5 Author: SleepySquash Date: Thu May 16 11:26:11 2024 +0300 Bootstrap WIP --- lib/api/backend/extension/chat.dart | 142 ++++----- lib/config.dart | 2 +- lib/domain/model/attachment.dart | 55 ++++ lib/domain/model/chat.dart | 21 ++ lib/domain/model/chat_call.dart | 73 +++++ lib/domain/model/chat_info.dart | 89 ++++++ lib/domain/model/chat_item.dart | 64 ++++ lib/domain/model/chat_item_quote.dart | 48 +++ lib/domain/model/file.dart | 23 +- lib/domain/model/native_file.dart | 69 +++++ lib/domain/model/sending_status.dart | 13 +- lib/domain/model/user.dart | 9 +- lib/domain/model_type_id.dart | 8 +- lib/provider/drift/chat_item.dart | 252 +++++++++++++++ lib/provider/drift/drift.dart | 23 +- lib/provider/drift/user.dart | 13 +- lib/provider/gql/components/chat.dart | 2 +- lib/provider/hive/chat.dart | 18 +- lib/provider/hive/chat_item.dart | 242 --------------- lib/provider/hive/chat_item_sorting.dart | 63 ---- lib/provider/hive/draft.dart | 18 +- lib/routes.dart | 5 + lib/store/chat.dart | 74 ++--- lib/store/chat_rx.dart | 289 +++++++++--------- lib/store/event/chat.dart | 12 +- lib/store/model/chat_call.dart | 19 ++ lib/store/model/chat_item.dart | 183 +++++++++++ lib/store/pagination.dart | 16 +- lib/store/pagination/drift.dart | 237 ++++++++++++++ lib/store/pagination/drift_graphql.dart | 95 ++++++ lib/store/pagination/graphql.dart | 2 +- lib/store/pagination/hive.dart | 40 +-- lib/store/pagination/hive_graphql.dart | 16 +- lib/ui/page/home/page/chat/controller.dart | 52 ++-- lib/ui/page/home/page/chat/info/view.dart | 11 +- lib/ui/page/home/page/chat/view.dart | 19 +- .../home/page/chat/widget/chat_forward.dart | 2 +- .../page/home/page/chat/widget/chat_item.dart | 2 +- .../video_thumbnail/video_thumbnail.dart | 46 +-- lib/ui/page/home/page/my_profile/view.dart | 20 +- lib/ui/page/home/page/user/view.dart | 10 +- lib/ui/page/home/tab/menu/view.dart | 22 +- macos/Podfile.lock | 12 +- test/e2e/steps/reads_message.dart | 2 +- test/e2e/steps/reply_message.dart | 8 +- test/unit/call_test.dart | 7 + test/unit/chat_attachment_test.dart | 4 + test/unit/chat_avatar_test.dart | 4 + test/unit/chat_delete_message_test.dart | 31 +- test/unit/chat_direct_link_test.dart | 3 + test/unit/chat_edit_message_test.dart | 19 +- test/unit/chat_hide_test.dart | 4 + test/unit/chat_leave_test.dart | 3 + test/unit/chat_members_test.dart | 39 ++- test/unit/chat_read_test.dart | 8 +- test/unit/chat_rename_test.dart | 4 + test/unit/chat_reply_message_test.dart | 9 +- test/unit/chat_split_message_test.dart | 8 + test/unit/pagination_combined_test.dart | 2 +- test/unit/pagination_test.dart | 9 +- test/unit/toggle_chat_mute_test.dart | 4 + test/widget/chat_attachment_test.dart | 25 +- test/widget/chat_direct_link_chat_test.dart | 8 +- test/widget/chat_edit_message_test.dart | 10 +- test/widget/chat_hide_test.dart | 9 +- test/widget/chat_rename_test.dart | 9 +- test/widget/chat_reply_message_test.dart | 10 +- test/widget/chat_update_attachments_test.dart | 86 +++--- test/widget/user_profile_test.dart | 3 + 69 files changed, 1879 insertions(+), 880 deletions(-) create mode 100644 lib/provider/drift/chat_item.dart delete mode 100644 lib/provider/hive/chat_item.dart delete mode 100644 lib/provider/hive/chat_item_sorting.dart create mode 100644 lib/store/pagination/drift.dart create mode 100644 lib/store/pagination/drift_graphql.dart diff --git a/lib/api/backend/extension/chat.dart b/lib/api/backend/extension/chat.dart index 6d830a516dc..4b7b846365f 100644 --- a/lib/api/backend/extension/chat.dart +++ b/lib/api/backend/extension/chat.dart @@ -18,19 +18,19 @@ import '../schema.dart'; import '/domain/model/attachment.dart'; import '/domain/model/avatar.dart'; -import '/domain/model/chat.dart'; import '/domain/model/chat_info.dart'; -import '/domain/model/chat_item.dart'; import '/domain/model/chat_item_quote.dart'; +import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; import '/domain/model/crop_area.dart'; import '/domain/model/mute_duration.dart'; import '/domain/model/user.dart'; -import '/provider/hive/chat.dart'; -import '/provider/hive/chat_item.dart'; import '/provider/hive/chat_member.dart'; +import '/provider/hive/chat.dart'; import '/store/chat.dart'; -import '/store/model/chat.dart'; +import '/store/model/chat_call.dart'; import '/store/model/chat_item.dart'; +import '/store/model/chat.dart'; import 'call.dart'; import 'file.dart'; import 'user.dart'; @@ -64,8 +64,8 @@ extension ChatConversion on ChatMixin { lastReads: lastReads.map((e) => LastChatRead(e.memberId, e.at)).toList(), lastDelivery: lastDelivery, - lastItem: lastItem?.toHive().value, - lastReadItem: lastReadItem?.toHive().value.id, + lastItem: lastItem?.toDto().value, + lastReadItem: lastReadItem?.toDto().value.id, unreadCount: unreadCount, totalCount: totalCount, ongoingCall: ongoingCall?.toModel(), @@ -86,8 +86,8 @@ extension ChatConversion on ChatMixin { /// Constructs a new [ChatData] from this [ChatMixin]. ChatData toData([RecentChatsCursor? recent, FavoriteChatsCursor? favorite]) { - var lastItem = this.lastItem?.toHive(); - var lastReadItem = this.lastReadItem?.toHive(); + var lastItem = this.lastItem?.toDto(); + var lastReadItem = this.lastReadItem?.toDto(); return ChatData( toHive(recent, favorite), @@ -108,9 +108,9 @@ extension ChatInfoConversion on ChatInfoMixin { action: action.toModel(), ); - /// Constructs a new [HiveChatInfo] from this [ChatInfoMixin]. - HiveChatInfo toHive(ChatItemsCursor cursor) => - HiveChatInfo(toModel(), cursor, ver); + /// Constructs a new [DtoChatInfo] from this [ChatInfoMixin]. + DtoChatInfo toDto(ChatItemsCursor cursor) => + DtoChatInfo(toModel(), cursor, ver); } /// Extension adding models construction from [ChatInfoMixin$Action]. @@ -143,18 +143,18 @@ extension ChatInfoActionConversion on ChatInfoMixin$Action { /// Extension adding models construction from [ChatCallMixin]. extension ChatCallConversion on ChatCallMixin { - /// Constructs a new [HiveChatCall] from this [ChatCallMixin]. - HiveChatCall toHive(ChatItemsCursor cursor) => - HiveChatCall(toModel(), cursor, ver); + /// Constructs a new [DtoChatCall] from this [ChatCallMixin]. + DtoChatCall toDto(ChatItemsCursor cursor) => + DtoChatCall(toModel(), cursor, ver); } /// Extension adding models construction from [ChatMessageMixin]. extension ChatMessageConversion on ChatMessageMixin { - /// Constructs a new [HiveChatItem]s from this [ChatMessageMixin]. - HiveChatItem toHive(ChatItemsCursor cursor) { - List items = repliesTo.map((e) => e.toHive()).toList(); + /// Constructs a new [DtoChatItem]s from this [ChatMessageMixin]. + DtoChatItem toDto(ChatItemsCursor cursor) { + List items = repliesTo.map((e) => e.toDto()).toList(); - return HiveChatMessage( + return DtoChatMessage( ChatMessage( id, chatId, @@ -186,8 +186,8 @@ extension NestedChatMessageConversion on NestedChatMessageMixin { attachments: attachments.map((e) => e.toModel()).toList(), ); - /// Constructs a new [HiveChatMessage] from this [NestedChatMessageMixin]. - HiveChatMessage toHive(ChatItemsCursor cursor) => HiveChatMessage( + /// Constructs a new [DtoChatMessage] from this [NestedChatMessageMixin]. + DtoChatMessage toDto(ChatItemsCursor cursor) => DtoChatMessage( toModel(), cursor, ver, @@ -197,10 +197,10 @@ extension NestedChatMessageConversion on NestedChatMessageMixin { /// Extension adding models construction from [ChatForwardMixin]. extension ChatForwardConversion on ChatForwardMixin { - /// Constructs the new [HiveChatItem]s from this [ChatForwardMixin]. - HiveChatItem toHive(ChatItemsCursor cursor) { - final HiveChatItemQuote item = quote.toHive(); - return HiveChatForward( + /// Constructs the new [DtoChatItem]s from this [ChatForwardMixin]. + DtoChatItem toDto(ChatItemsCursor cursor) { + final DtoChatItemQuote item = quote.toDto(); + return DtoChatForward( ChatForward( id, chatId, @@ -217,11 +217,11 @@ extension ChatForwardConversion on ChatForwardMixin { /// Extension adding models construction from [NestedChatForwardMixin]. extension NestedChatForwardConversion on NestedChatForwardMixin { - /// Constructs the new [HiveChatForward]s from this [NestedChatForwardMixin]. - HiveChatForward toHive(ChatItemsCursor cursor) { - final HiveChatItemQuote item = quote.toHive(); + /// Constructs the new [DtoChatForward]s from this [NestedChatForwardMixin]. + DtoChatForward toDto(ChatItemsCursor cursor) { + final DtoChatItemQuote item = quote.toDto(); - return HiveChatForward( + return DtoChatForward( ChatForward( id, chatId, @@ -238,11 +238,11 @@ extension NestedChatForwardConversion on NestedChatForwardMixin { /// Extension adding models construction from [NestedChatForwardMixin$Quote]. extension NestedChatForwardItemConversion on NestedChatForwardMixin$Quote { - /// Constructs a new [HiveChatItem]s from this [NestedChatForwardMixin$Quote]. - HiveChatItemQuote toHive() { + /// Constructs a new [DtoChatItem]s from this [NestedChatForwardMixin$Quote]. + DtoChatItemQuote toDto() { if ($$typename == 'ChatMessageQuote') { final q = this as NestedChatForwardMixin$Quote$ChatMessageQuote; - return HiveChatItemQuote( + return DtoChatItemQuote( ChatMessageQuote( text: q.text, attachments: q.attachments.map((e) => e.toModel()).toList(), @@ -252,7 +252,7 @@ extension NestedChatForwardItemConversion on NestedChatForwardMixin$Quote { original?.cursor, ); } else if ($$typename == 'ChatCallQuote') { - return HiveChatItemQuote( + return DtoChatItemQuote( ChatCallQuote( author: author.id, at: at, @@ -261,7 +261,7 @@ extension NestedChatForwardItemConversion on NestedChatForwardMixin$Quote { ); } else if ($$typename == 'ChatInfoQuote') { final q = this as NestedChatForwardMixin$Quote$ChatInfoQuote; - return HiveChatItemQuote( + return DtoChatItemQuote( ChatInfoQuote( action: q.action.toModel(), author: author.id, @@ -320,61 +320,61 @@ extension NestedChatForwardChatInfoQuoteActionConversion /// Extension adding models construction from /// [GetMessages$Query$Chat$Items$Edges]. extension GetMessagesConversion on GetMessages$Query$Chat$Items$Edges { - /// Constructs the new [HiveChatItem]s from this + /// Constructs the new [DtoChatItem]s from this /// [GetMessages$Query$Chat$Items$Edges]. - HiveChatItem toHive() => _chatItem(node, cursor); + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from [ChatMixin$LastItem]. extension ChatLastItemConversion on ChatMixin$LastItem { - /// Constructs the new [HiveChatItem]s from this [ChatMixin$LastItem]. - HiveChatItem toHive() => _chatItem(node, cursor); + /// Constructs the new [DtoChatItem]s from this [ChatMixin$LastItem]. + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from [ChatMixin$LastReadItem]. extension ChatLastReadItemConversion on ChatMixin$LastReadItem { - /// Constructs the new [HiveChatItem]s from this [ChatMixin$LastReadItem]. - HiveChatItem toHive() => _chatItem(node, cursor); + /// Constructs the new [DtoChatItem]s from this [ChatMixin$LastReadItem]. + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from [ChatForwardMixin$Quote]. extension ChatForwardMixinItemConversion on ChatForwardMixin$Quote { - /// Constructs the new [HiveChatItemQuote]s from this [ChatForwardMixin$Quote]. - HiveChatItemQuote toHive() => _chatItemQuote(this); + /// Constructs the new [DtoChatItemQuote]s from this [ChatForwardMixin$Quote]. + DtoChatItemQuote toDto() => _chatItemQuote(this); } /// Extension adding models construction from [ChatMessageMixin$RepliesTo]. extension ChatMessageMixinRepliesToConversion on ChatMessageMixin$RepliesTo { - /// Constructs the new [HiveChatItemQuote] from this + /// Constructs the new [DtoChatItemQuote] from this /// [ChatMessageMixin$RepliesTo]. - HiveChatItemQuote toHive() => _chatItemQuote(this); + DtoChatItemQuote toDto() => _chatItemQuote(this); } /// Extension adding models construction from /// [ChatEventsVersionedMixin$Events$EventChatItemEdited$RepliesTo$Changed]. extension EventChatItemEditedRepliesToConversion on ChatEventsVersionedMixin$Events$EventChatItemEdited$RepliesTo$Changed { - /// Constructs the new [HiveChatItemQuote]s from this + /// Constructs the new [DtoChatItemQuote]s from this /// [ChatEventsVersionedMixin$Events$EventChatItemEdited$RepliesTo$Changed]. - HiveChatItemQuote toHive() => _chatItemQuote(this); + DtoChatItemQuote toDto() => _chatItemQuote(this); } /// Extension adding models construction from /// [ChatEventsVersionedMixin$Events$EventChatItemPosted$Item]. extension EventChatItemPostedConversion on ChatEventsVersionedMixin$Events$EventChatItemPosted$Item { - /// Constructs the new [HiveChatItem]s from this + /// Constructs the new [DtoChatItem]s from this /// [ChatEventsVersionedMixin$Events$EventChatItemPosted$Item]. - HiveChatItem toHive() => _chatItem(node, cursor); + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from /// [ChatEventsVersionedMixin$Events$EventChatLastItemUpdated$LastItem]. extension EventChatLastItemUpdatedConversion on ChatEventsVersionedMixin$Events$EventChatLastItemUpdated$LastItem { - /// Constructs the new [HiveChatItem]s from this + /// Constructs the new [DtoChatItem]s from this /// [ChatEventsVersionedMixin$Events$EventChatLastItemUpdated$LastItem]. - HiveChatItem toHive() => _chatItem(node, cursor); + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from [ChatMessageQuoteMixin]. @@ -390,8 +390,8 @@ extension ChatMessageQuoteConversion on ChatMessageQuoteMixin { attachments: attachments.map((e) => e.toModel()).toList(), ); - /// Constructs a new [HiveChatItemQuote] from this [ChatMessageQuoteMixin]. - HiveChatItemQuote toHive() => HiveChatItemQuote( + /// Constructs a new [DtoChatItemQuote] from this [ChatMessageQuoteMixin]. + DtoChatItemQuote toDto() => DtoChatItemQuote( toModel(), original == null ? null @@ -410,8 +410,8 @@ extension ChatCallQuoteConversion on ChatCallQuoteMixin { at: at, ); - /// Constructs a new [HiveChatItemQuote] from this [ChatCallQuoteMixin]. - HiveChatItemQuote toHive() => HiveChatItemQuote( + /// Constructs a new [DtoChatItemQuote] from this [ChatCallQuoteMixin]. + DtoChatItemQuote toDto() => DtoChatItemQuote( toModel(), original == null ? null @@ -431,8 +431,8 @@ extension ChatInfoQuoteConversion on ChatInfoQuoteMixin { action: action.toModel(), ); - /// Constructs a new [HiveChatItemQuote] from this [ChatInfoQuoteMixin]. - HiveChatItemQuote toHive() => HiveChatItemQuote( + /// Constructs a new [DtoChatItemQuote] from this [ChatInfoQuoteMixin]. + DtoChatItemQuote toDto() => DtoChatItemQuote( toModel(), original == null ? null @@ -442,8 +442,8 @@ extension ChatInfoQuoteConversion on ChatInfoQuoteMixin { /// Extension adding models construction from [GetMessage$Query$ChatItem]. extension GetMessageConversion on GetMessage$Query$ChatItem { - /// Constructs a new [HiveChatItem] from this [GetMessage$Query$ChatItem]. - HiveChatItem toHive() => _chatItem(node, cursor); + /// Constructs a new [DtoChatItem] from this [GetMessage$Query$ChatItem]. + DtoChatItem toDto() => _chatItem(node, cursor); } /// Extension adding models construction from [ChatInfoQuoteMixin$Action]. @@ -677,33 +677,33 @@ Attachment _attachment(dynamic node) { throw UnimplementedError('$node is not implemented'); } -/// Constructs a new [HiveChatItem]s based on the [node] and [cursor]. -HiveChatItem _chatItem(dynamic node, ChatItemsCursor cursor) { +/// Constructs a new [DtoChatItem]s based on the [node] and [cursor]. +DtoChatItem _chatItem(dynamic node, ChatItemsCursor cursor) { if (node is ChatInfoMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } else if (node is ChatCallMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } else if (node is ChatMessageMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } else if (node is NestedChatMessageMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } else if (node is ChatForwardMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } else if (node is NestedChatForwardMixin) { - return node.toHive(cursor); + return node.toDto(cursor); } throw UnimplementedError('$node is not implemented'); } -/// Constructs a new [HiveChatItemQuote] based on the [node]. -HiveChatItemQuote _chatItemQuote(dynamic node) { +/// Constructs a new [DtoChatItemQuote] based on the [node]. +DtoChatItemQuote _chatItemQuote(dynamic node) { if (node is ChatMessageQuoteMixin) { - return node.toHive(); + return node.toDto(); } else if (node is ChatInfoQuoteMixin) { - return node.toHive(); + return node.toDto(); } else if (node is ChatCallQuoteMixin) { - return node.toHive(); + return node.toDto(); } throw UnimplementedError('$node is not implemented'); diff --git a/lib/config.dart b/lib/config.dart index 458c29581fa..abf96202dd7 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -150,7 +150,7 @@ class Config { String wsUrl = const bool.hasEnvironment('SOCAPP_WS_URL') ? const String.fromEnvironment('SOCAPP_WS_URL') - : (document['server']?['ws']?['url'] ?? ws); + : (document['server']?['ws']?['url'] ?? 'ws://localhost'); int wsPort = const bool.hasEnvironment('SOCAPP_WS_PORT') ? const int.fromEnvironment('SOCAPP_WS_PORT') diff --git a/lib/domain/model/attachment.dart b/lib/domain/model/attachment.dart index 1643856bfab..31c0a7df145 100644 --- a/lib/domain/model/attachment.dart +++ b/lib/domain/model/attachment.dart @@ -20,6 +20,7 @@ import 'dart:io'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; import '../model_type_id.dart'; @@ -40,6 +41,15 @@ abstract class Attachment extends HiveObject { required this.filename, }); + /// Constructs an [Attachment] from the provided [json]. + factory Attachment.fromJson(Map json) => + switch (json['runtimeType']) { + 'ImageAttachment' => ImageAttachment.fromJson(json), + 'FileAttachment' => FileAttachment.fromJson(json), + 'LocalAttachment' => LocalAttachment.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + /// Unique ID of this [Attachment]. @HiveField(0) AttachmentId id; @@ -62,9 +72,18 @@ abstract class Attachment extends HiveObject { /// Returns [DownloadStatus] of this [Attachment]. DownloadStatus get downloadStatus => downloading?.status.value ?? DownloadStatus.notStarted; + + /// Returns a [Map] representing this [Attachment]. + Map toJson() => switch (runtimeType) { + const (ImageAttachment) => (this as ImageAttachment).toJson(), + const (FileAttachment) => (this as FileAttachment).toJson(), + const (LocalAttachment) => (this as LocalAttachment).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// Image [Attachment]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.imageAttachment) class ImageAttachment extends Attachment { ImageAttachment({ @@ -76,6 +95,10 @@ class ImageAttachment extends Attachment { required this.small, }); + /// Constructs a [ImageAttachment] from the provided [json]. + factory ImageAttachment.fromJson(Map json) => + _$ImageAttachmentFromJson(json); + /// Big view [ImageFile] of this [ImageAttachment], scaled proportionally to /// `800px` of its maximum dimension (either width or height). @HiveField(3) @@ -90,9 +113,15 @@ class ImageAttachment extends Attachment { /// `30px` of its maximum dimension (either width or height). @HiveField(5) ImageFile small; + + /// Returns a [Map] representing this [ImageAttachment]. + @override + Map toJson() => + _$ImageAttachmentToJson(this)..['runtimeType'] = 'ImageAttachment'; } /// Plain file [Attachment]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.fileAttachment) class FileAttachment extends Attachment { FileAttachment({ @@ -101,6 +130,10 @@ class FileAttachment extends Attachment { required super.filename, }); + /// Constructs a [FileAttachment] from the provided [json]. + factory FileAttachment.fromJson(Map json) => + _$FileAttachmentFromJson(json); + /// Indicator whether this [FileAttachment] has already been [init]ialized. bool _initialized = false; @@ -149,6 +182,11 @@ class FileAttachment extends Attachment { /// Cancels the [downloading] of this [FileAttachment]. void cancelDownload() => downloading?.cancel(); + + /// Returns a [Map] representing this [FileAttachment]. + @override + Map toJson() => + _$FileAttachmentToJson(this)..['runtimeType'] = 'FileAttachment'; } /// Unique ID of an [Attachment]. @@ -158,9 +196,16 @@ class AttachmentId extends NewType { /// Constructs a dummy [AttachmentId]. factory AttachmentId.local() => AttachmentId('local_${const Uuid().v4()}'); + + /// Constructs a [AttachmentId] from the provided [val]. + factory AttachmentId.fromJson(String val) = AttachmentId; + + /// Returns a [String] representing this [AttachmentId]. + String toJson() => val; } /// [Attachment] stored in a [NativeFile] locally. +@JsonSerializable() @HiveType(typeId: ModelTypeId.localAttachment) class LocalAttachment extends Attachment { LocalAttachment(this.file, {SendingStatus status = SendingStatus.error}) @@ -176,11 +221,16 @@ class LocalAttachment extends Attachment { filename: file.name, ); + /// Constructs a [LocalAttachment] from the provided [json]. + factory LocalAttachment.fromJson(Map json) => + _$LocalAttachmentFromJson(json); + /// [NativeFile] representing this [LocalAttachment]. @HiveField(3) NativeFile file; /// [SendingStatus] of this [LocalAttachment]. + @JsonKey(toJson: SendingStatusJson.toJson) final Rx status; /// Upload progress of this [LocalAttachment]. @@ -191,4 +241,9 @@ class LocalAttachment extends Attachment { /// [Completer] resolving once this [LocalAttachment]'s reading is finished. final Rx?> read = Rx?>(null); + + /// Returns a [Map] representing this [LocalAttachment]. + @override + Map toJson() => + _$LocalAttachmentToJson(this)..['runtimeType'] = 'LocalAttachment'; } diff --git a/lib/domain/model/chat.dart b/lib/domain/model/chat.dart index 70b878bdc66..52ec3462492 100644 --- a/lib/domain/model/chat.dart +++ b/lib/domain/model/chat.dart @@ -18,6 +18,7 @@ import 'package:collection/collection.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '../model_type_id.dart'; import '/api/backend/schema.dart' show ChatKind; @@ -305,10 +306,15 @@ class Chat extends HiveObject implements Comparable { } /// Member of a [Chat]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatMember) class ChatMember implements Comparable { ChatMember(this.user, this.joinedAt); + /// Constructs a [ChatMember] from the provided [json]. + factory ChatMember.fromJson(Map json) => + _$ChatMemberFromJson(json); + /// [User] represented by this [ChatMember]. @HiveField(0) User user; @@ -326,6 +332,9 @@ class ChatMember implements Comparable { return result; } + + /// Returns a [Map] representing this [ChatMember]. + Map toJson() => _$ChatMemberToJson(this); } /// [PreciseDateTime] of when a [Chat] was read last time by a [User]. @@ -351,6 +360,9 @@ class ChatId extends NewType implements Comparable { /// local [Chat] is created. factory ChatId.local(UserId id) => ChatId('local_${id.val}'); + /// Constructs a [ChatId] from the provided [val]. + factory ChatId.fromJson(String val) = ChatId; + /// Indicates whether this [ChatId] is a dummy ID. bool get isLocal => val.startsWith('local_'); @@ -365,6 +377,9 @@ class ChatId extends NewType implements Comparable { @override int compareTo(ChatId other) => val.compareTo(other.val); + + /// Returns a [String] representing this [ChatId]. + String toJson() => val; } /// Name of a [Chat]. @@ -383,6 +398,9 @@ class ChatName extends NewType { /// Creates a [ChatName] without any validation. const factory ChatName.unchecked(String val) = ChatName._; + /// Constructs a [ChatName] from the provided [val]. + factory ChatName.fromJson(String val) = ChatName.unchecked; + /// Regular expression for a [ChatName] validation. static final RegExp _regExp = RegExp(r'^[^\s].{0,98}[^\s]$'); @@ -395,6 +413,9 @@ class ChatName extends NewType { return null; } } + + /// Returns a [String] representing this [ChatName]. + String toJson() => val; } /// Position of this [Chat] in the favorites list of the authenticated [MyUser]. diff --git a/lib/domain/model/chat_call.dart b/lib/domain/model/chat_call.dart index dd64a869e64..edb54c05f2a 100644 --- a/lib/domain/model/chat_call.dart +++ b/lib/domain/model/chat_call.dart @@ -16,6 +16,7 @@ // . import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '../model_type_id.dart'; import '/api/backend/schema.dart'; @@ -28,6 +29,7 @@ import 'user.dart'; part 'chat_call.g.dart'; /// Call in a [Chat]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatCall) class ChatCall extends ChatItem { ChatCall( @@ -44,6 +46,10 @@ class ChatCall extends ChatItem { this.dialed, }); + /// Constructs a [ChatCall] from the provided [json]. + factory ChatCall.fromJson(Map json) => + _$ChatCallFromJson(json); + /// Indicator whether this [ChatCall] is intended to start with video. @HiveField(5) final bool withVideo; @@ -88,9 +94,15 @@ class ChatCall extends ChatItem { set finishReason(ChatCallFinishReason? reason) { finishReasonIndex = reason?.index; } + + /// Returns a [Map] representing this [ChatCall]. + @override + Map toJson() => + _$ChatCallToJson(this)..['runtimeType'] = 'ChatCall'; } /// Member of a [ChatCall]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatCallMember) class ChatCallMember { ChatCallMember({ @@ -99,6 +111,10 @@ class ChatCallMember { required this.joinedAt, }); + /// Constructs a [ChatCallMember] from the provided [json]. + factory ChatCallMember.fromJson(Map json) => + _$ChatCallMemberFromJson(json); + /// [User] representing this [ChatCallMember]. @HiveField(0) final User user; @@ -110,6 +126,9 @@ class ChatCallMember { /// [PreciseDateTime] when this [ChatCallMember] joined the [ChatCall]. @HiveField(2) final PreciseDateTime joinedAt; + + /// Returns a [Map] representing this [ChatCallMember]. + Map toJson() => _$ChatCallMemberToJson(this); } /// One-time secret credentials to authenticate a [ChatCall] with on a media @@ -118,6 +137,9 @@ class ChatCallMember { class ChatCallCredentials extends HiveObject { ChatCallCredentials(this.val); + /// Constructs the [ChatCallCredentials] from the provided [val]. + factory ChatCallCredentials.fromJson(String val) = ChatCallCredentials; + /// Actual value of these [ChatCallCredentials]. @HiveField(0) final String val; @@ -136,46 +158,97 @@ class ChatCallCredentials extends HiveObject { /// Returns a copy of these [ChatCallCredentials] with the given [val]. ChatCallCredentials copyWith({String? val}) => ChatCallCredentials(val ?? this.val); + + /// Returns a [String] representing these [ChatCallCredentials]. + String toJson() => val; } /// Link for joining a [ChatCall] room on a media server. @HiveType(typeId: ModelTypeId.chatCallRoomJoinLink) class ChatCallRoomJoinLink extends NewType { const ChatCallRoomJoinLink(super.val); + + /// Constructs a [ChatCallRoomJoinLink] from the provided [val]. + factory ChatCallRoomJoinLink.fromJson(String val) = ChatCallRoomJoinLink; + + /// Returns a [String] representing this [ChatCallRoomJoinLink]. + String toJson() => val; } /// ID of the device the authenticated [MyUser] starts a [ChatCall] from. @HiveType(typeId: ModelTypeId.chatCallDeviceId) class ChatCallDeviceId extends NewType { const ChatCallDeviceId(super.val); + + /// Constructs a [ChatCallDeviceId] from the provided [val]. + factory ChatCallDeviceId.fromJson(String val) = ChatCallDeviceId; + + /// Returns a [String] representing this [ChatCallDeviceId]. + String toJson() => val; } /// [ChatMember]s being dialed by a [ChatCall]. abstract class ChatMembersDialed { const ChatMembersDialed(); + + /// Constructs a [ChatMembersDialed] from the provided [json]. + factory ChatMembersDialed.fromJson(Map json) => + switch (json['runtimeType']) { + 'ChatMembersDialedAll' => ChatMembersDialedAll.fromJson(json), + 'ChatMembersDialedConcrete' => ChatMembersDialedConcrete.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + + /// Returns a [Map] representing this [ChatMembersDialed]. + Map toJson() => switch (runtimeType) { + const (ChatMembersDialedAll) => (this as ChatMembersDialedAll).toJson(), + const (ChatMembersDialedConcrete) => + (this as ChatMembersDialedConcrete).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// Information about all [ChatMember]s of a [Chat] being dialed (or redialed) /// by a [ChatCall]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatMembersDialedAll) class ChatMembersDialedAll implements ChatMembersDialed { const ChatMembersDialedAll(this.answeredMembers); + /// Constructs a [ChatMembersDialedAll] from the provided [json]. + factory ChatMembersDialedAll.fromJson(Map json) => + _$ChatMembersDialedAllFromJson(json); + /// [ChatMember]s who answered (joined or declined) the [ChatCall] already, so /// are not dialed anymore. @HiveField(0) final List answeredMembers; + + /// Returns a [Map] representing this [ChatMembersDialedAll]. + @override + Map toJson() => _$ChatMembersDialedAllToJson(this) + ..['runtimeType'] = 'ChatMembersDialedAll'; } /// Information about concrete [ChatMember]s of a [Chat] being dialed (or /// redialed) by a [ChatCall]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatMembersDialedConcrete) class ChatMembersDialedConcrete implements ChatMembersDialed { const ChatMembersDialedConcrete(this.members); + /// Constructs a [ChatMembersDialedConcrete] from the provided [json]. + factory ChatMembersDialedConcrete.fromJson(Map json) => + _$ChatMembersDialedConcreteFromJson(json); + /// Concrete [ChatMember]s who are dialed (or redialed) by the [ChatCall]. /// /// Guaranteed to be non-empty. @HiveField(0) final List members; + + /// Returns a [Map] representing this [ChatMembersDialedConcrete]. + @override + Map toJson() => _$ChatMembersDialedConcreteToJson(this) + ..['runtimeType'] = 'ChatMembersDialedConcrete'; } diff --git a/lib/domain/model/chat_info.dart b/lib/domain/model/chat_info.dart index 2dc63286c14..50baeec8d2e 100644 --- a/lib/domain/model/chat_info.dart +++ b/lib/domain/model/chat_info.dart @@ -16,6 +16,7 @@ // . import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '../model_type_id.dart'; import 'avatar.dart'; @@ -28,6 +29,7 @@ part 'chat_info.g.dart'; /// Information about an action taken upon a [Chat]. @HiveType(typeId: ModelTypeId.chatInfo) +@JsonSerializable() class ChatInfo extends ChatItem { ChatInfo( super.id, @@ -37,9 +39,18 @@ class ChatInfo extends ChatItem { required this.action, }); + /// Constructs a [ChatInfo] from the provided [json]. + factory ChatInfo.fromJson(Map json) => + _$ChatInfoFromJson(json); + /// [ChatInfoAction] taken upon the [Chat]. @HiveField(5) final ChatInfoAction action; + + /// Returns a [Map] representing this [ChatInfo]. + @override + Map toJson() => + _$ChatInfoToJson(this)..['runtimeType'] = 'ChatInfo'; } /// Possible kinds of a [ChatInfoAction]. @@ -55,15 +66,48 @@ enum ChatInfoActionKind { abstract class ChatInfoAction { const ChatInfoAction(); + /// Constructs a [ChatInfoAction] from the provided [json]. + factory ChatInfoAction.fromJson(Map json) => + switch (json['runtimeType']) { + 'ChatInfoActionAvatarUpdated' => + ChatInfoActionAvatarUpdated.fromJson(json), + 'ChatInfoActionCreated' => ChatInfoActionCreated.fromJson(json), + 'ChatInfoActionMemberAdded' => ChatInfoActionMemberAdded.fromJson(json), + 'ChatInfoActionMemberRemoved' => + ChatInfoActionMemberRemoved.fromJson(json), + 'ChatInfoActionNameUpdated' => ChatInfoActionNameUpdated.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + /// [ChatInfoActionKind] of this event. ChatInfoActionKind get kind; + + /// Returns a [Map] representing this [ChatInfoAction]. + Map toJson() => switch (runtimeType) { + const (ChatInfoActionAvatarUpdated) => + (this as ChatInfoActionAvatarUpdated).toJson(), + const (ChatInfoActionCreated) => + (this as ChatInfoActionCreated).toJson(), + const (ChatInfoActionMemberAdded) => + (this as ChatInfoActionMemberAdded).toJson(), + const (ChatInfoActionMemberRemoved) => + (this as ChatInfoActionMemberRemoved).toJson(), + const (ChatInfoActionNameUpdated) => + (this as ChatInfoActionNameUpdated).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// [ChatInfoAction] about a [ChatAvatar] being updated. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoActionAvatarUpdated) class ChatInfoActionAvatarUpdated implements ChatInfoAction { const ChatInfoActionAvatarUpdated(this.avatar); + /// Constructs a [ChatInfoActionAvatarUpdated] from the provided [json]. + factory ChatInfoActionAvatarUpdated.fromJson(Map json) => + _$ChatInfoActionAvatarUpdatedFromJson(json); + /// New [ChatAvatar] of the [Chat]. /// /// `null` means that the old [ChatAvatar] was removed. @@ -72,26 +116,46 @@ class ChatInfoActionAvatarUpdated implements ChatInfoAction { @override ChatInfoActionKind get kind => ChatInfoActionKind.avatarUpdated; + + /// Returns a [Map] representing this [ChatInfoActionAvatarUpdated]. + @override + Map toJson() => _$ChatInfoActionAvatarUpdatedToJson(this) + ..['runtimeType'] = 'ChatInfoActionAvatarUpdated'; } /// [ChatInfoAction] about a [Chat] being created. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoActionCreated) class ChatInfoActionCreated implements ChatInfoAction { const ChatInfoActionCreated(this.directLinkSlug); + /// Constructs a [ChatInfoActionCreated] from the provided [json]. + factory ChatInfoActionCreated.fromJson(Map json) => + _$ChatInfoActionCreatedFromJson(json); + /// [ChatDirectLinkSlug] used to create the [Chat], if any. @HiveField(0) final ChatDirectLinkSlug? directLinkSlug; @override ChatInfoActionKind get kind => ChatInfoActionKind.created; + + /// Returns a [Map] representing this [ChatInfoActionCreated]. + @override + Map toJson() => _$ChatInfoActionCreatedToJson(this) + ..['runtimeType'] = 'ChatInfoActionCreated'; } /// [ChatInfoAction] about a [ChatAvatar] being updated. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoActionMemberAdded) class ChatInfoActionMemberAdded implements ChatInfoAction { const ChatInfoActionMemberAdded(this.user, this.directLinkSlug); + /// Constructs a [ChatInfoActionMemberAdded] from the provided [json]. + factory ChatInfoActionMemberAdded.fromJson(Map json) => + _$ChatInfoActionMemberAddedFromJson(json); + /// [User] who became a [ChatMember]. /// /// If the same as [ChatItem.author], then the [User] joined the [Chat] by @@ -105,13 +169,23 @@ class ChatInfoActionMemberAdded implements ChatInfoAction { @override ChatInfoActionKind get kind => ChatInfoActionKind.memberAdded; + + /// Returns a [Map] representing this [ChatInfoActionMemberAdded]. + @override + Map toJson() => _$ChatInfoActionMemberAddedToJson(this) + ..['runtimeType'] = 'ChatInfoActionMemberAdded'; } /// [ChatInfoAction] about a [ChatMember] being removed from a [Chat]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoActionMemberRemoved) class ChatInfoActionMemberRemoved implements ChatInfoAction { const ChatInfoActionMemberRemoved(this.user); + /// Constructs a [ChatInfoActionMemberRemoved] from the provided [json]. + factory ChatInfoActionMemberRemoved.fromJson(Map json) => + _$ChatInfoActionMemberRemovedFromJson(json); + /// [User] who was removed from the [Chat]. /// /// If the same as [ChatItem.author], then the [User] left the [Chat] by @@ -121,13 +195,23 @@ class ChatInfoActionMemberRemoved implements ChatInfoAction { @override ChatInfoActionKind get kind => ChatInfoActionKind.memberRemoved; + + /// Returns a [Map] representing this [ChatInfoActionMemberRemoved]. + @override + Map toJson() => _$ChatInfoActionMemberRemovedToJson(this) + ..['runtimeType'] = 'ChatInfoActionMemberRemoved'; } /// [ChatInfoAction] about a [ChatName] being updated. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoActionNameUpdated) class ChatInfoActionNameUpdated implements ChatInfoAction { const ChatInfoActionNameUpdated(this.name); + /// Constructs a [ChatInfoActionNameUpdated] from the provided [json]. + factory ChatInfoActionNameUpdated.fromJson(Map json) => + _$ChatInfoActionNameUpdatedFromJson(json); + /// New [ChatName] of the [Chat]. /// /// `null` means that the old [ChatName] was removed. @@ -136,4 +220,9 @@ class ChatInfoActionNameUpdated implements ChatInfoAction { @override ChatInfoActionKind get kind => ChatInfoActionKind.nameUpdated; + + /// Returns a [Map] representing this [ChatInfoActionNameUpdated]. + @override + Map toJson() => _$ChatInfoActionNameUpdatedToJson(this) + ..['runtimeType'] = 'ChatInfoActionNameUpdated'; } diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index a773503ef9b..b6b423a5b60 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -21,12 +21,15 @@ import 'dart:ui'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:messenger/l10n/l10n.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:uuid/uuid.dart'; import '../model_type_id.dart'; import '/util/new_type.dart'; import 'attachment.dart'; import 'chat.dart'; +import 'chat_call.dart'; +import 'chat_info.dart'; import 'chat_item_quote.dart'; import 'precise_date_time/precise_date_time.dart'; import 'sending_status.dart'; @@ -46,6 +49,16 @@ abstract class ChatItem { status ?? (id.isLocal ? SendingStatus.error : SendingStatus.sent), ); + /// Constructs a [ChatItem] from the provided [json]. + factory ChatItem.fromJson(Map json) => + switch (json['runtimeType']) { + 'ChatMessage' => ChatMessage.fromJson(json), + 'ChatCall' => ChatCall.fromJson(json), + 'ChatInfo' => ChatInfo.fromJson(json), + 'ChatForward' => ChatForward.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + /// Unique ID of this [ChatItem]. @HiveField(0) final ChatItemId id; @@ -63,6 +76,7 @@ abstract class ChatItem { PreciseDateTime at; /// [SendingStatus] of this [ChatItem]. + @JsonKey(toJson: SendingStatusJson.toJson) final Rx status; /// Returns combined [at] and [id] unique identifier of this [ChatItem]. @@ -81,9 +95,19 @@ abstract class ChatItem { @override String toString() => '$runtimeType($id, $chatId)'; + + /// Returns a [Map] representing this [ChatItem]. + Map toJson() => switch (runtimeType) { + const (ChatMessage) => (this as ChatMessage).toJson(), + const (ChatCall) => (this as ChatCall).toJson(), + const (ChatInfo) => (this as ChatInfo).toJson(), + const (ChatForward) => (this as ChatForward).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// Message in a [Chat]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatMessage) class ChatMessage extends ChatItem { ChatMessage( @@ -98,6 +122,10 @@ class ChatMessage extends ChatItem { this.attachments = const [], }); + /// Constructs a [ChatMessage] from the provided [json]. + factory ChatMessage.fromJson(Map json) => + _$ChatMessageFromJson(json); + /// [ChatItemQuote]s of the [ChatItem]s this [ChatMessage] replies to. @HiveField(5) List repliesTo; @@ -137,9 +165,18 @@ class ChatMessage extends ChatItem { ), ); } + + @override + String toString() => '$runtimeType($id, $chatId, text: $text)'; + + /// Returns a [Map] representing this [ChatMessage]. + @override + Map toJson() => + _$ChatMessageToJson(this)..['runtimeType'] = 'ChatMessage'; } /// Quote of a [ChatItem] forwarded to some [Chat]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatForward) class ChatForward extends ChatItem { ChatForward( @@ -150,12 +187,21 @@ class ChatForward extends ChatItem { required this.quote, }); + /// Constructs a [ChatForward] from the provided [json]. + factory ChatForward.fromJson(Map json) => + _$ChatForwardFromJson(json); + /// [ChatItemQuote] of the forwarded [ChatItem]. /// /// Re-forwarding a [ChatForward] is indistinguishable from just forwarding /// its inner [ChatMessage] ([ChatItemQuote] depth will still be just 1). @HiveField(5) final ChatItemQuote quote; + + /// Returns a [Map] representing this [ChatForward]. + @override + Map toJson() => + _$ChatForwardToJson(this)..['runtimeType'] = 'ChatForward'; } /// Unique ID of a [ChatItem]. @@ -166,8 +212,14 @@ class ChatItemId extends NewType { /// Constructs a dummy [ChatItemId]. factory ChatItemId.local() => ChatItemId('local.${const Uuid().v4()}'); + /// Constructs a [ChatItemId] from the provided [val]. + factory ChatItemId.fromJson(String val) = ChatItemId; + /// Indicates whether this [ChatItemId] is a dummy ID. bool get isLocal => val.startsWith('local.'); + + /// Returns a [String] representing this [ChatItemId]. + String toJson() => val; } class ChatBotText { @@ -197,6 +249,9 @@ class ChatBotText { class ChatMessageText extends NewType { const ChatMessageText(super.val); + /// Constructs a [ChatMessageText] from the provided [val]. + factory ChatMessageText.fromJson(String val) = ChatMessageText; + factory ChatMessageText.bot({ String? title, Map localized = const {}, @@ -244,6 +299,9 @@ class ChatMessageText extends NewType { return chunks.map((e) => ChatMessageText(e)).toList(); } + + /// Returns a [String] representing this [ChatMessageText]. + String toJson() => val; } /// Combined [at] and [id] unique identifier of a [ChatItem]. @@ -264,6 +322,9 @@ class ChatItemKey implements Comparable { ); } + /// Constructs a [ChatItemKey] from the provided [val]. + factory ChatItemKey.fromJson(String val) = ChatItemKey.fromString; + /// [ChatItemId] part of this [ChatItemKey]. final ChatItemId id; @@ -273,6 +334,9 @@ class ChatItemKey implements Comparable { @override String toString() => '${at.microsecondsSinceEpoch}_$id'; + /// Returns a [String] representing this [ChatItemKey]. + String toJson() => toString(); + @override bool operator ==(Object other) => other is ChatItemKey && id == other.id && at == other.at; diff --git a/lib/domain/model/chat_item_quote.dart b/lib/domain/model/chat_item_quote.dart index 037dac0976f..d223a15d657 100644 --- a/lib/domain/model/chat_item_quote.dart +++ b/lib/domain/model/chat_item_quote.dart @@ -16,6 +16,7 @@ // . import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '../model_type_id.dart'; import 'attachment.dart'; @@ -65,6 +66,15 @@ abstract class ChatItemQuote { throw Exception('$item is not supported to be quoted'); } + /// Constructs a [ChatItemQuote] from the provided [json]. + factory ChatItemQuote.fromJson(Map json) => + switch (json['runtimeType']) { + 'ChatMessageQuote' => ChatMessageQuote.fromJson(json), + 'ChatCallQuote' => ChatCallQuote.fromJson(json), + 'ChatInfoQuote' => ChatInfoQuote.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + /// Quoted [ChatItem]. /// /// `null` if the original [ChatItem] was deleted or is unavailable for the @@ -79,9 +89,18 @@ abstract class ChatItemQuote { /// [PreciseDateTime] when the quoted [ChatItem] was created. @HiveField(2) final PreciseDateTime at; + + /// Returns a [Map] representing this [ChatItemQuote]. + Map toJson() => switch (runtimeType) { + const (ChatMessageQuote) => (this as ChatMessageQuote).toJson(), + const (ChatCallQuote) => (this as ChatCallQuote).toJson(), + const (ChatInfoQuote) => (this as ChatInfoQuote).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// [ChatItemQuote] of a [ChatMessage]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatMessageQuote) class ChatMessageQuote extends ChatItemQuote { ChatMessageQuote({ @@ -92,6 +111,10 @@ class ChatMessageQuote extends ChatItemQuote { this.attachments = const [], }); + /// Constructs a [ChatMessageQuote] from the provided [json]. + factory ChatMessageQuote.fromJson(Map json) => + _$ChatMessageQuoteFromJson(json); + /// [ChatMessageText] the quoted [ChatMessage] had when this [ChatItemQuote] /// was made. @HiveField(3) @@ -101,9 +124,15 @@ class ChatMessageQuote extends ChatItemQuote { /// made. @HiveField(4) final List attachments; + + /// Returns a [Map] representing this [ChatMessageQuote]. + @override + Map toJson() => + _$ChatMessageQuoteToJson(this)..['runtimeType'] = 'ChatMessageQuote'; } /// [ChatItemQuote] of a [ChatCall]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatCallQuote) class ChatCallQuote extends ChatItemQuote { ChatCallQuote({ @@ -111,9 +140,19 @@ class ChatCallQuote extends ChatItemQuote { required super.author, required super.at, }); + + /// Constructs a [ChatCallQuote] from the provided [json]. + factory ChatCallQuote.fromJson(Map json) => + _$ChatCallQuoteFromJson(json); + + /// Returns a [Map] representing this [ChatCallQuote]. + @override + Map toJson() => + _$ChatCallQuoteToJson(this)..['runtimeType'] = 'ChatCallQuote'; } /// [ChatItemQuote] of a [ChatInfo]. +@JsonSerializable() @HiveType(typeId: ModelTypeId.chatInfoQuote) class ChatInfoQuote extends ChatItemQuote { ChatInfoQuote({ @@ -123,8 +162,17 @@ class ChatInfoQuote extends ChatItemQuote { required this.action, }); + /// Constructs a [ChatInfoQuote] from the provided [json]. + factory ChatInfoQuote.fromJson(Map json) => + _$ChatInfoQuoteFromJson(json); + /// [ChatMessageText] the quoted [ChatMessage] had when this [ChatItemQuote] /// was made. @HiveField(3) final ChatInfoAction? action; + + /// Returns a [Map] representing this [ChatInfoQuote]. + @override + Map toJson() => + _$ChatInfoQuoteToJson(this)..['runtimeType'] = 'ChatInfoQuote'; } diff --git a/lib/domain/model/file.dart b/lib/domain/model/file.dart index dff1db47e27..3786a660d9b 100644 --- a/lib/domain/model/file.dart +++ b/lib/domain/model/file.dart @@ -33,6 +33,14 @@ abstract class StorageFile extends HiveObject { this.size, }); + /// Constructs a [StorageFile] from the provided [json]. + factory StorageFile.fromJson(Map json) => + switch (json['runtimeType']) { + 'PlainFile' => PlainFile.fromJson(json), + 'ImageFile' => ImageFile.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + /// Relative reference to this [StorageFile] on a file storage. /// /// Prepend it with a file storage URL to obtain the full link to this @@ -102,6 +110,13 @@ abstract class StorageFile extends HiveObject { return result; } + + /// Returns a [Map] representing this [StorageFile]. + Map toJson() => switch (runtimeType) { + const (PlainFile) => (this as PlainFile).toJson(), + const (ImageFile) => (this as ImageFile).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; } /// Plain-[StorageFile] on a file storage. @@ -119,7 +134,9 @@ class PlainFile extends StorageFile { _$PlainFileFromJson(json); /// Returns a [Map] representing this [PlainFile]. - Map toJson() => _$PlainFileToJson(this); + @override + Map toJson() => + _$PlainFileToJson(this)..['runtimeType'] = 'PlainFile'; } /// Image-[StorageFile] on a file storage. @@ -152,7 +169,9 @@ class ImageFile extends StorageFile { final ThumbHash? thumbhash; /// Returns a [Map] representing this [ImageFile]. - Map toJson() => _$ImageFileToJson(this); + @override + Map toJson() => + _$ImageFileToJson(this)..['runtimeType'] = 'ImageFile'; } /// [Base64URL][1]-encoded [ThumbHash][2]. diff --git a/lib/domain/model/native_file.dart b/lib/domain/model/native_file.dart index 12e1e9f2177..98abfe1a77c 100644 --- a/lib/domain/model/native_file.dart +++ b/lib/domain/model/native_file.dart @@ -15,6 +15,7 @@ // along with this program. If not, see // . +import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui'; @@ -24,6 +25,7 @@ import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:mime/mime.dart'; import 'package:mutex/mutex.dart'; @@ -31,7 +33,10 @@ import '../model_type_id.dart'; import '/util/mime.dart'; import '/util/platform_utils.dart'; +part 'native_file.g.dart'; + /// Native file representation. +@JsonSerializable() class NativeFile { NativeFile({ required this.name, @@ -81,6 +86,12 @@ class NativeFile { stream: file.openRead().asBroadcastStream(), ); + /// Constructs a [NativeFile] from the provided [json]. + factory NativeFile.fromJson(Map json) => + _$NativeFileFromJson(json) + ..dimensions.value = _SizeExtension.fromJson(json['dimensions']) + ..bytes.value = _Uint8ListExtension.fromJson(json['bytes']); + /// Absolute path for a cached copy of this file. final String? path; @@ -88,6 +99,7 @@ class NativeFile { final String name; /// Byte data of this file. + @JsonKey(includeFromJson: false, includeToJson: false) final Rx bytes; /// Size of this file in bytes. @@ -97,15 +109,18 @@ class NativeFile { /// /// __Note:__ To ensure [MediaType] is correct, invoke /// [ensureCorrectMediaType] before accessing this field. + @JsonKey(fromJson: _MediaType.fromValue, toJson: _MediaType.toValue) MediaType? mime; /// [Size] of the image this [NativeFile] represents, if [isImage]. + @JsonKey(includeFromJson: false, includeToJson: false) final Rx dimensions; /// [Mutex] for synchronized access to the [readFile]. final Mutex _readGuard = Mutex(); /// Content of this file as a stream. + @JsonKey(includeFromJson: false, includeToJson: false) Stream>? _readStream; /// Merged stream of [bytes] and [_readStream] representing the whole file. @@ -162,6 +177,7 @@ class NativeFile { /// Returns contents of this file as a broadcast [Stream]. /// /// Once read, it cannot be rewinded. + @JsonKey(includeFromJson: false, includeToJson: false) Stream>? get stream { if (_readStream == null) return null; @@ -173,6 +189,11 @@ class NativeFile { return _mergedStream; } + /// Returns a [Map] representing this [NativeFile]. + Map toJson() => _$NativeFileToJson(this) + ..['dimensions'] = dimensions.value?.toJson() + ..['bytes'] = bytes.value?.toJson(); + /// Ensures [mime] is correctly assigned. /// /// Tries to determine the [mime] type by reading the [stream]. @@ -290,3 +311,51 @@ class NativeFileAdapter extends TypeAdapter { ..write(obj.mime); } } + +/// Extension adding methods to construct the [MediaType] to/from primitive +/// types. +/// +/// Intended to be used as [JsonKey.toJson] and [JsonKey.fromJson] methods. +extension _MediaType on MediaType { + /// Returns a [MediaType] constructed from the provided [val]. + static MediaType? fromValue(String? val) => + val == null ? null : MediaType.parse(val); + + /// Returns a [String] representing this [MediaType]. + static String? toValue(MediaType? val) => val?.toString(); +} + +/// Extension adding methods to construct the [Size] to/from primitive types. +/// +/// Intended to be used as [JsonKey.toJson] and [JsonKey.fromJson] methods. +extension _SizeExtension on Size { + /// Returns a [Size] constructed from the provided [json]. + static Size? fromJson(Map? json) { + if (json == null) { + return null; + } + + return Size(json['width'] as double, json['height'] as double); + } + + /// Returns a [String] representing this [Size]. + Map toJson() => {'width': width, 'height': height}; +} + +/// Extension adding methods to construct the [Uint8List] to/from primitive +/// types. +/// +/// Intended to be used as [JsonKey.toJson] and [JsonKey.fromJson] methods. +extension _Uint8ListExtension on Uint8List { + /// Returns a [Uint8List] constructed from the provided [val]. + static Uint8List? fromJson(String? val) { + if (val == null) { + return null; + } + + return base64.decode(val); + } + + /// Returns a [String] representing this [Uint8List]. + String toJson() => base64.encode(this); +} diff --git a/lib/domain/model/sending_status.dart b/lib/domain/model/sending_status.dart index 8ccc20b02f5..2d0f5c4e8dc 100644 --- a/lib/domain/model/sending_status.dart +++ b/lib/domain/model/sending_status.dart @@ -15,7 +15,9 @@ // along with this program. If not, see // . +import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '/domain/model_type_id.dart'; @@ -35,5 +37,14 @@ enum SendingStatus { /// Error occurred. @HiveField(2) - error + error, +} + +/// Extension adding methods to construct the [SendingStatus] to/from primitive +/// types. +/// +/// Intended to be used as [JsonKey.toJson] and [JsonKey.fromJson] methods. +extension SendingStatusJson on SendingStatus { + /// Returns a [String] representing the [value]. + static String toJson(Rx value) => value.value.name; } diff --git a/lib/domain/model/user.dart b/lib/domain/model/user.dart index 547e0a3c38e..cce9ecc4e82 100644 --- a/lib/domain/model/user.dart +++ b/lib/domain/model/user.dart @@ -36,6 +36,7 @@ import 'user_call_cover.dart'; part 'user.g.dart'; /// User of a system impersonating a real person. +@JsonSerializable() @HiveType(typeId: ModelTypeId.user) class User extends HiveObject { User( @@ -56,6 +57,9 @@ class User extends HiveObject { this.contacts = const [], }) : _dialog = dialog; + /// Constructs a [User] from the provided [json]. + factory User.fromJson(Map json) => _$UserFromJson(json); + /// Unique ID of this [User]. /// /// Once assigned it never changes. @@ -157,6 +161,9 @@ class User extends HiveObject { @override String toString() => '$runtimeType($id)'; + + /// Returns a [Map] representing this [User]. + Map toJson() => _$UserToJson(this); } /// Unique ID of an [User]. @@ -166,7 +173,7 @@ class User extends HiveObject { class UserId extends NewType implements Comparable { const UserId(super.val); - /// Constructs a [UserId] from the provided [json]. + /// Constructs a [UserId] from the provided [val]. factory UserId.fromJson(String val) = UserId; /// Returns a [String] representing this [UserId]. diff --git a/lib/domain/model_type_id.dart b/lib/domain/model_type_id.dart index 379025a1ae1..3e295ae757e 100644 --- a/lib/domain/model_type_id.dart +++ b/lib/domain/model_type_id.dart @@ -75,10 +75,10 @@ class ModelTypeId { static const imageAttachment = 53; static const fileAttachment = 54; static const chatItemsCursor = 55; - static const hiveChatInfo = 56; - static const hiveChatCall = 57; - static const hiveChatMessage = 58; - static const hiveChatForward = 59; + static const dtoChatInfo = 56; + static const dtoChatCall = 57; + static const dtoChatMessage = 58; + static const dtoChatForward = 59; static const incomingChatCallsCursor = 60; static const mediaSettings = 61; static const chatCallDeviceId = 62; diff --git a/lib/provider/drift/chat_item.dart b/lib/provider/drift/chat_item.dart new file mode 100644 index 00000000000..3e8f8d8470f --- /dev/null +++ b/lib/provider/drift/chat_item.dart @@ -0,0 +1,252 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:log_me/log_me.dart'; + +import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; +import '/domain/model/precise_date_time/precise_date_time.dart'; +import '/domain/model/sending_status.dart'; +import '/store/model/chat_item.dart'; +import 'common.dart'; +import 'drift.dart'; + +/// [ChatItem] to be stored in a [Table]. +@DataClassName('ChatItemRow') +class ChatItems extends Table { + @override + Set get primaryKey => {id}; + + TextColumn get id => text()(); + TextColumn get chatId => text()(); + TextColumn get authorId => text()(); + IntColumn get at => integer().map(const PreciseDateTimeConverter())(); + IntColumn get status => intEnum()(); + TextColumn get data => text()(); + TextColumn get cursor => text().nullable()(); + TextColumn get ver => text()(); +} + +/// [Table] for [RxChat.messages] history retrieving. +@DataClassName('ChatItemViewRow') +class ChatItemViews extends Table { + @override + Set get primaryKey => {chatId, chatItemId}; + + TextColumn get chatId => text()(); + TextColumn get chatItemId => text().references( + ChatItems, + #id, + onUpdate: KeyAction.cascade, + onDelete: KeyAction.cascade, + )(); +} + +/// [DriftProviderBase] for manipulating the persisted [ChatItem]s. +class ChatItemDriftProvider extends DriftProviderBase { + ChatItemDriftProvider(super.database); + + /// Creates or updates the a view for the provided [chatItemId] in [chatId]. + Future upsertView(ChatId chatId, ChatItemId chatItemId) async { + Log.debug('upsertView($chatId, $chatItemId)'); + + await safe((db) async { + final view = + ChatItemViewRow(chatId: chatId.val, chatItemId: chatItemId.val); + await db + .into(db.chatItemViews) + .insert(view, onConflict: DoUpdate((_) => view)); + }); + } + + /// Creates or updates the provided [item] in the database. + /// + /// If [toView] is `true`, then also adds a view to the [ChatItemViews]. + Future upsert(DtoChatItem item, {bool toView = false}) async { + Log.debug('upsert($item) toView($toView)'); + + final result = await safe((db) async { + final ChatItemRow row = item.toDb(); + final DtoChatItem stored = _ChatItemDb.fromDb( + await db + .into(db.chatItems) + .insertReturning(row, onConflict: DoUpdate((_) => row)), + ); + + if (toView) { + final ChatItemViewRow row = item.toView(); + await db + .into(db.chatItemViews) + .insertReturning(row, onConflict: DoUpdate((_) => row)); + } + + return stored; + }); + + return result ?? item; + } + + /// Creates or updates the provided [items] in the database. + /// + /// If [toView] is `true`, then also adds the views to the [ChatItemViews]. + Future> upsertBulk( + Iterable items, { + bool toView = false, + }) async { + final result = await safe((db) async { + Log.debug('upsertBulk(${items.length} items) toView($toView)'); + + await db.batch((batch) { + for (var item in items) { + final ChatItemRow row = item.toDb(); + batch.insert(db.chatItems, row, onConflict: DoUpdate((_) => row)); + } + + if (toView) { + for (var item in items) { + final ChatItemViewRow row = item.toView(); + batch.insert(db.chatItemViews, row, onConflict: DoNothing()); + } + } + }); + + return items.toList(); + }); + + return result ?? items; + } + + /// Returns the [DtoChatItem] stored in the database by the provided [id], if + /// any. + Future read(ChatItemId id) async { + return await safe((db) async { + final stmt = db.select(db.chatItems)..where((u) => u.id.equals(id.val)); + final ChatItemRow? row = await stmt.getSingleOrNull(); + + if (row == null) { + return null; + } + + return _ChatItemDb.fromDb(row); + }); + } + + /// Deletes the [DtoChatItem] identified by the provided [id] from the + /// database. + Future delete(ChatItemId id) async { + await safe((db) async { + final stmt = db.delete(db.chatItems)..where((e) => e.id.equals(id.val)); + await stmt.goAndReturn(); + }); + } + + /// Deletes all the [DtoChatItem]s stored in the database. + Future clear() async { + await safe((db) async { + await db.delete(db.chatItems).go(); + await db.delete(db.chatItemViews).go(); + }); + } + + /// Returns the [DtoChatItem]s being in a historical view order of the + /// provided [chatId]. + Future> view( + ChatId chatId, { + int? before, + int? after, + PreciseDateTime? around, + }) async { + if (db == null) { + return []; + } + + if (around != null) { + final stmt = db!.chatItemsAround( + chatId.val, + around, + (before ?? 50).toDouble(), + after ?? 50, + ); + + return (await stmt.get()) + .map( + (r) => ChatItemRow( + id: r.id, + chatId: r.chatId, + authorId: r.authorId, + at: r.at, + status: r.status, + data: r.data, + cursor: r.cursor, + ver: r.ver, + ), + ) + .map(_ChatItemDb.fromDb) + .toList(); + } + + final stmt = db!.select(db!.chatItemViews).join([ + innerJoin( + db!.chatItems, + db!.chatItems.id.equalsExp(db!.chatItemViews.chatItemId), + ), + ]); + + stmt.where(db!.chatItemViews.chatId.equals(chatId.val)); + stmt.orderBy([OrderingTerm.desc(db!.chatItems.at)]); + + if (after != null || before != null) { + stmt.limit((after ?? 0) + (before ?? 0)); + } + + return (await stmt.get()) + .map((rows) => rows.readTable(db!.chatItems)) + .map(_ChatItemDb.fromDb) + .toList(); + } +} + +/// Extension adding conversion methods from [ChatItemRow] to [DtoChatItem]. +extension _ChatItemDb on DtoChatItem { + /// Returns the [DtoChatItem] from the provided [ChatItemRow]. + static DtoChatItem fromDb(ChatItemRow e) { + return DtoChatItem.fromJson(jsonDecode(e.data)); + } + + /// Constructs a [ChatItemRow] from this [DtoChatItem]. + ChatItemRow toDb() { + return ChatItemRow( + id: value.id.val, + chatId: value.chatId.val, + authorId: value.author.id.val, + at: value.at, + status: value.status.value, + data: jsonEncode(toJson()), + cursor: cursor?.val, + ver: ver.val, + ); + } + + /// Constructs a [ChatItemViewRow] from this [DtoChatItem]. + ChatItemViewRow toView() { + return ChatItemViewRow(chatItemId: value.id.val, chatId: value.chatId.val); + } +} diff --git a/lib/provider/drift/drift.dart b/lib/provider/drift/drift.dart index c180d1981d1..969bfa14072 100644 --- a/lib/provider/drift/drift.dart +++ b/lib/provider/drift/drift.dart @@ -21,6 +21,8 @@ import 'package:get/get.dart' show DisposableInterface; import 'package:log_me/log_me.dart'; import '/domain/model/precise_date_time/precise_date_time.dart'; +import '/domain/model/sending_status.dart'; +import 'chat_item.dart'; import 'common.dart'; import 'connection/connection.dart'; import 'user.dart'; @@ -28,12 +30,29 @@ import 'user.dart'; part 'drift.g.dart'; /// [DriftDatabase] storing data locally. -@DriftDatabase(tables: [Users]) +@DriftDatabase( + tables: [Users, ChatItems, ChatItemViews], + queries: { + 'chatItemsAround': '' + 'SELECT * FROM ' + '(SELECT * FROM chat_item_views ' + 'INNER JOIN chat_items ON chat_items.id = chat_item_views.chat_item_id ' + 'WHERE chat_item_views.chat_id = :chat_id AND at <= :at ' + 'ORDER BY at DESC LIMIT :before + 1) as a ' + 'UNION ' + 'SELECT * FROM ' + '(SELECT * FROM chat_item_views ' + 'INNER JOIN chat_items ON chat_items.id = chat_item_views.chat_item_id ' + 'WHERE chat_item_views.chat_id = :chat_id AND at > :at ' + 'ORDER BY at ASC LIMIT :after) as b ' + 'ORDER BY at ASC;', + }, +) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? e]) : super(e ?? connect()); @override - int get schemaVersion => 1; + int get schemaVersion => 2; @override MigrationStrategy get migration { diff --git a/lib/provider/drift/user.dart b/lib/provider/drift/user.dart index 8f8a19b6839..c3edecc3bd8 100644 --- a/lib/provider/drift/user.dart +++ b/lib/provider/drift/user.dart @@ -57,7 +57,7 @@ class Users extends Table { TextColumn get blockedVer => text()(); } -/// [DriftProviderBase] for manipulating the [User]s stored. +/// [DriftProviderBase] for manipulating the persisted [User]s. class UserDriftProvider extends DriftProviderBase { UserDriftProvider(super.database); @@ -68,9 +68,10 @@ class UserDriftProvider extends DriftProviderBase { Future upsert(DtoUser user) async { final result = await safe((db) async { final DtoUser stored = _UserDb.fromDb( - await db - .into(db.users) - .insertReturning(user.toDb(), mode: InsertMode.replace), + await db.into(db.users).insertReturning( + user.toDb(), + onConflict: DoUpdate((_) => user.toDb()), + ), ); _controllers[stored.id]?.add(stored); @@ -139,7 +140,7 @@ class UserDriftProvider extends DriftProviderBase { /// Extension adding conversion methods from [UserRow] to [DtoUser]. extension _UserDb on DtoUser { - /// Returns the [DtoUser] from the provided [UserRow]. + /// Constructs a [DtoUser] from the provided [UserRow]. static DtoUser fromDb(UserRow e) { return DtoUser( User( @@ -173,7 +174,7 @@ extension _UserDb on DtoUser { ); } - /// Returns the [UserRow] from this [DtoUser]. + /// Constructs a [UserRow] from this [DtoUser]. UserRow toDb() { return UserRow( id: value.id.val, diff --git a/lib/provider/gql/components/chat.dart b/lib/provider/gql/components/chat.dart index 08d44e77d79..a4526a6fe9f 100644 --- a/lib/provider/gql/components/chat.dart +++ b/lib/provider/gql/components/chat.dart @@ -294,7 +294,7 @@ mixin ChatGraphQlMixin { bool onlyAttachments = false, }) async { Log.debug( - 'chatItems($id, $first, $after, $last, $before, $onlyAttachments)', + 'chatItems($id, first: $first, after: $after, last: $last, before: $before, onlyAttachments: $onlyAttachments)', '$runtimeType', ); diff --git a/lib/provider/hive/chat.dart b/lib/provider/hive/chat.dart index 6cc9828f181..934b0171157 100644 --- a/lib/provider/hive/chat.dart +++ b/lib/provider/hive/chat.dart @@ -17,13 +17,14 @@ import 'package:hive_flutter/hive_flutter.dart'; +import '/domain/model_type_id.dart'; import '/domain/model/attachment.dart'; import '/domain/model/avatar.dart'; -import '/domain/model/chat.dart'; import '/domain/model/chat_call.dart'; import '/domain/model/chat_info.dart'; -import '/domain/model/chat_item.dart'; import '/domain/model/chat_item_quote.dart'; +import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; import '/domain/model/crop_area.dart'; import '/domain/model/file.dart'; import '/domain/model/mute_duration.dart'; @@ -31,12 +32,11 @@ import '/domain/model/native_file.dart'; import '/domain/model/precise_date_time/precise_date_time.dart'; import '/domain/model/sending_status.dart'; import '/domain/model/user.dart'; -import '/domain/model_type_id.dart'; -import '/store/model/chat.dart'; +import '/store/model/chat_call.dart'; import '/store/model/chat_item.dart'; +import '/store/model/chat.dart'; import '/util/log.dart'; import 'base.dart'; -import 'chat_item.dart'; part 'chat.g.dart'; @@ -83,14 +83,14 @@ class ChatHiveProvider extends HiveLazyProvider Hive.maybeRegisterAdapter(ChatNameAdapter()); Hive.maybeRegisterAdapter(ChatVersionAdapter()); Hive.maybeRegisterAdapter(CropAreaAdapter()); + Hive.maybeRegisterAdapter(DtoChatCallAdapter()); + Hive.maybeRegisterAdapter(DtoChatForwardAdapter()); + Hive.maybeRegisterAdapter(DtoChatInfoAdapter()); + Hive.maybeRegisterAdapter(DtoChatMessageAdapter()); Hive.maybeRegisterAdapter(FavoriteChatsCursorAdapter()); Hive.maybeRegisterAdapter(FavoriteChatsListVersionAdapter()); Hive.maybeRegisterAdapter(FileAttachmentAdapter()); Hive.maybeRegisterAdapter(HiveChatAdapter()); - Hive.maybeRegisterAdapter(HiveChatCallAdapter()); - Hive.maybeRegisterAdapter(HiveChatForwardAdapter()); - Hive.maybeRegisterAdapter(HiveChatInfoAdapter()); - Hive.maybeRegisterAdapter(HiveChatMessageAdapter()); Hive.maybeRegisterAdapter(ImageAttachmentAdapter()); Hive.maybeRegisterAdapter(ImageFileAdapter()); Hive.maybeRegisterAdapter(LastChatReadAdapter()); diff --git a/lib/provider/hive/chat_item.dart b/lib/provider/hive/chat_item.dart deleted file mode 100644 index 372349a4106..00000000000 --- a/lib/provider/hive/chat_item.dart +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:hive_flutter/hive_flutter.dart'; - -import '/domain/model/attachment.dart'; -import '/domain/model/chat.dart'; -import '/domain/model/chat_call.dart'; -import '/domain/model/chat_info.dart'; -import '/domain/model/chat_item.dart'; -import '/domain/model/chat_item_quote.dart'; -import '/domain/model/file.dart'; -import '/domain/model/native_file.dart'; -import '/domain/model/precise_date_time/precise_date_time.dart'; -import '/domain/model/sending_status.dart'; -import '/domain/model/user.dart'; -import '/domain/model_type_id.dart'; -import '/store/model/chat_item.dart'; -import '/util/log.dart'; -import 'base.dart'; - -part 'chat_item.g.dart'; - -/// [Hive] storage for [ChatItem]s. -class ChatItemHiveProvider extends HiveLazyProvider - implements IterableHiveProvider { - ChatItemHiveProvider(this.id); - - /// ID of a [Chat] this provider is bound to. - final ChatId id; - - @override - Stream get boxEvents => box.watch(); - - @override - String get boxName => 'messages_$id'; - - @override - void registerAdapters() { - Log.debug('registerAdapters($id)', '$runtimeType'); - - Hive.maybeRegisterAdapter(AttachmentIdAdapter()); - Hive.maybeRegisterAdapter(ChatCallAdapter()); - Hive.maybeRegisterAdapter(ChatCallMemberAdapter()); - Hive.maybeRegisterAdapter(ChatCallQuoteAdapter()); - Hive.maybeRegisterAdapter(ChatForwardAdapter()); - Hive.maybeRegisterAdapter(ChatIdAdapter()); - Hive.maybeRegisterAdapter(ChatInfoActionAvatarUpdatedAdapter()); - Hive.maybeRegisterAdapter(ChatInfoActionCreatedAdapter()); - Hive.maybeRegisterAdapter(ChatInfoActionMemberAddedAdapter()); - Hive.maybeRegisterAdapter(ChatInfoActionMemberRemovedAdapter()); - Hive.maybeRegisterAdapter(ChatInfoActionNameUpdatedAdapter()); - Hive.maybeRegisterAdapter(ChatInfoAdapter()); - Hive.maybeRegisterAdapter(ChatInfoQuoteAdapter()); - Hive.maybeRegisterAdapter(ChatItemIdAdapter()); - Hive.maybeRegisterAdapter(ChatItemVersionAdapter()); - Hive.maybeRegisterAdapter(ChatItemsCursorAdapter()); - Hive.maybeRegisterAdapter(ChatMemberAdapter()); - Hive.maybeRegisterAdapter(ChatMembersDialedAllAdapter()); - Hive.maybeRegisterAdapter(ChatMembersDialedConcreteAdapter()); - Hive.maybeRegisterAdapter(ChatMessageAdapter()); - Hive.maybeRegisterAdapter(ChatMessageQuoteAdapter()); - Hive.maybeRegisterAdapter(ChatMessageTextAdapter()); - Hive.maybeRegisterAdapter(FileAttachmentAdapter()); - Hive.maybeRegisterAdapter(HiveChatCallAdapter()); - Hive.maybeRegisterAdapter(HiveChatForwardAdapter()); - Hive.maybeRegisterAdapter(HiveChatInfoAdapter()); - Hive.maybeRegisterAdapter(HiveChatMessageAdapter()); - Hive.maybeRegisterAdapter(ImageAttachmentAdapter()); - Hive.maybeRegisterAdapter(LocalAttachmentAdapter()); - Hive.maybeRegisterAdapter(MediaTypeAdapter()); - Hive.maybeRegisterAdapter(NativeFileAdapter()); - Hive.maybeRegisterAdapter(PreciseDateTimeAdapter()); - Hive.maybeRegisterAdapter(SendingStatusAdapter()); - Hive.maybeRegisterAdapter(ThumbHashAdapter()); - } - - @override - Iterable get keys => keysSafe.map((e) => ChatItemId(e)); - - @override - Future> get values => valuesSafe; - - @override - Future put(HiveChatItem item) async { - Log.trace('put($item)', '$runtimeType'); - await putSafe(item.value.id.toString(), item); - } - - @override - Future get(ChatItemId key) async { - Log.trace('get($key)', '$runtimeType'); - return await getSafe(key.toString()); - } - - @override - Future remove(ChatItemId key) async { - Log.trace('remove($key)', '$runtimeType'); - await deleteSafe(key.toString()); - } -} - -/// Persisted in [Hive] storage [ChatItem]'s [value]. -abstract class HiveChatItem extends HiveObject { - HiveChatItem(this.value, this.cursor, this.ver); - - /// Persisted [ChatItem] model. - @HiveField(0) - ChatItem value; - - /// Cursor of a [ChatItem] this [HiveChatItem] represents. - @HiveField(1) - ChatItemsCursor? cursor; - - /// Version of a [ChatItem]'s state. - /// - /// It increases monotonically, so may be used (and is intended to) for - /// tracking state's actuality. - @HiveField(2) - final ChatItemVersion ver; - - @override - String toString() => '$runtimeType($value, $cursor, $ver)'; -} - -/// Persisted in [Hive] storage [ChatInfo]'s [value]. -@HiveType(typeId: ModelTypeId.hiveChatInfo) -class HiveChatInfo extends HiveChatItem { - HiveChatInfo( - super.value, - super.cursor, - super.ver, - ); -} - -/// Persisted in [Hive] storage [ChatCall]'s [value]. -@HiveType(typeId: ModelTypeId.hiveChatCall) -class HiveChatCall extends HiveChatItem { - HiveChatCall( - super.value, - super.cursor, - super.ver, - ); -} - -/// Persisted in [Hive] storage [ChatMessage]'s [value]. -@HiveType(typeId: ModelTypeId.hiveChatMessage) -class HiveChatMessage extends HiveChatItem { - HiveChatMessage( - super.value, - super.cursor, - super.ver, - this.repliesToCursors, - ); - - /// Constructs a [HiveChatMessage] in a [SendingStatus.sending] state. - factory HiveChatMessage.sending({ - required ChatId chatId, - required UserId me, - ChatMessageText? text, - List repliesTo = const [], - List attachments = const [], - ChatItemId? existingId, - PreciseDateTime? existingDateTime, - }) => - HiveChatMessage( - ChatMessage( - existingId ?? ChatItemId.local(), - chatId, - User(me, UserNum('1234123412341234')), - existingDateTime ?? PreciseDateTime.now(), - text: text, - repliesTo: repliesTo, - attachments: attachments, - status: SendingStatus.sending, - ), - null, - ChatItemVersion('0'), - [], - ); - - /// Cursors of the [ChatMessage.repliesTo] list. - @HiveField(3) - List? repliesToCursors; - - /// Returns a copy of this [HiveChatMessage] with the provided parameters. - HiveChatMessage copyWith({ - ChatItem? value, - ChatItemsCursor? cursor, - ChatItemVersion? ver, - List? repliesToCursors, - }) { - return HiveChatMessage( - value ?? this.value, - cursor ?? this.cursor, - ver ?? this.ver, - repliesToCursors ?? this.repliesToCursors, - ); - } -} - -/// Persisted in [Hive] storage [ChatForward]'s [value]. -@HiveType(typeId: ModelTypeId.hiveChatForward) -class HiveChatForward extends HiveChatItem { - HiveChatForward( - super.value, - super.cursor, - super.ver, - this.quoteCursor, - ); - - /// Cursor of a [ChatForward.quote]. - @HiveField(3) - ChatItemsCursor? quoteCursor; -} - -/// Persisted in [Hive] storage [ChatItemQuote]'s [value]. -class HiveChatItemQuote { - HiveChatItemQuote(this.value, this.cursor); - - /// [ChatItemQuote] itself. - @HiveField(0) - final ChatItemQuote value; - - /// Cursor of a [ChatItemQuote.original]. - @HiveField(1) - ChatItemsCursor? cursor; -} diff --git a/lib/provider/hive/chat_item_sorting.dart b/lib/provider/hive/chat_item_sorting.dart deleted file mode 100644 index bfaf2398ff4..00000000000 --- a/lib/provider/hive/chat_item_sorting.dart +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, -// -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU Affero General Public License v3.0 as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for -// more details. -// -// You should have received a copy of the GNU Affero General Public License v3.0 -// along with this program. If not, see -// . - -import 'package:hive_flutter/hive_flutter.dart'; - -import '/domain/model/chat.dart'; -import '/domain/model/chat_item.dart'; -import '/util/log.dart'; -import 'base.dart'; - -/// [Hive] storage for [ChatItemId]s sorted by the [ChatItemKey]s. -class ChatItemSortingHiveProvider extends HiveBaseProvider { - ChatItemSortingHiveProvider(this.id); - - /// ID of a [Chat] this provider is bound to. - final ChatId id; - - @override - Stream get boxEvents => box.watch(); - - @override - String get boxName => 'messages_sorting_$id'; - - @override - void registerAdapters() { - Log.debug('registerAdapters($id)', '$runtimeType'); - - Hive.maybeRegisterAdapter(ChatItemIdAdapter()); - } - - /// Returns a list of [ChatItemKey] keys stored in the [Hive]. - Iterable get keys => - keysSafe.map((e) => ChatItemKey.fromString(e)); - - /// Returns a list of [ChatItemId]s from [Hive]. - Iterable get values => valuesSafe; - - /// Puts the provided [key] to [Hive]. - Future put(ChatItemKey key) async { - Log.trace('put($key))', '$runtimeType'); - await putSafe(key.toString(), key.id); - } - - /// Removes the provided [ChatItemKey] from [Hive]. - Future remove(ChatItemKey key) async { - Log.trace('remove($key)', '$runtimeType'); - await deleteSafe(key.toString()); - } -} diff --git a/lib/provider/hive/draft.dart b/lib/provider/hive/draft.dart index 62e8e3a5f44..391681633d8 100644 --- a/lib/provider/hive/draft.dart +++ b/lib/provider/hive/draft.dart @@ -19,23 +19,23 @@ import 'package:hive_flutter/hive_flutter.dart'; import '/domain/model/attachment.dart'; import '/domain/model/avatar.dart'; -import '/domain/model/chat.dart'; import '/domain/model/chat_call.dart'; import '/domain/model/chat_info.dart'; -import '/domain/model/chat_item.dart'; import '/domain/model/chat_item_quote.dart'; +import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; import '/domain/model/crop_area.dart'; import '/domain/model/file.dart'; import '/domain/model/native_file.dart'; import '/domain/model/precise_date_time/precise_date_time.dart'; import '/domain/model/sending_status.dart'; -import '/domain/model/user.dart'; import '/domain/model/user_call_cover.dart'; -import '/store/model/chat.dart'; +import '/domain/model/user.dart'; +import '/store/model/chat_call.dart'; import '/store/model/chat_item.dart'; +import '/store/model/chat.dart'; import '/util/log.dart'; import 'base.dart'; -import 'chat_item.dart'; /// [Hive] storage for [ChatMessage]s being [RxChat.draft]s. class DraftHiveProvider extends HiveBaseProvider { @@ -75,11 +75,11 @@ class DraftHiveProvider extends HiveBaseProvider { Hive.maybeRegisterAdapter(ChatNameAdapter()); Hive.maybeRegisterAdapter(ChatVersionAdapter()); Hive.maybeRegisterAdapter(CropAreaAdapter()); + Hive.maybeRegisterAdapter(DtoChatCallAdapter()); + Hive.maybeRegisterAdapter(DtoChatForwardAdapter()); + Hive.maybeRegisterAdapter(DtoChatInfoAdapter()); + Hive.maybeRegisterAdapter(DtoChatMessageAdapter()); Hive.maybeRegisterAdapter(FileAttachmentAdapter()); - Hive.maybeRegisterAdapter(HiveChatCallAdapter()); - Hive.maybeRegisterAdapter(HiveChatForwardAdapter()); - Hive.maybeRegisterAdapter(HiveChatInfoAdapter()); - Hive.maybeRegisterAdapter(HiveChatMessageAdapter()); Hive.maybeRegisterAdapter(ImageAttachmentAdapter()); Hive.maybeRegisterAdapter(ImageFileAdapter()); Hive.maybeRegisterAdapter(LocalAttachmentAdapter()); diff --git a/lib/routes.dart b/lib/routes.dart index 860bdb1d657..4c9c67b216e 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -42,6 +42,7 @@ import 'domain/service/user.dart'; import 'firebase_options.dart'; import 'l10n/l10n.dart'; import 'main.dart' show handlePushNotification; +import 'provider/drift/chat_item.dart'; import 'provider/drift/user.dart'; import 'provider/gql/graphql.dart'; import 'provider/hive/application_settings.dart'; @@ -537,6 +538,7 @@ class AppRouterDelegate extends RouterDelegate ]); deps.put(UserDriftProvider(Get.find())); + deps.put(ChatItemDriftProvider(Get.find())); AbstractSettingsRepository settingsRepository = deps.put( @@ -574,6 +576,7 @@ class AppRouterDelegate extends RouterDelegate Get.find(), Get.find(), Get.find(), + Get.find(), callRepository, Get.find(), userRepository, @@ -673,6 +676,7 @@ class AppRouterDelegate extends RouterDelegate ]); deps.put(UserDriftProvider(Get.find())); + deps.put(ChatItemDriftProvider(Get.find())); GraphQlProvider graphQlProvider = Get.find(); @@ -730,6 +734,7 @@ class AppRouterDelegate extends RouterDelegate Get.find(), Get.find(), Get.find(), + Get.find(), callRepository, Get.find(), userRepository, diff --git a/lib/store/chat.dart b/lib/store/chat.dart index 3993321923a..f47e2894fbb 100644 --- a/lib/store/chat.dart +++ b/lib/store/chat.dart @@ -47,6 +47,7 @@ import '/domain/model/user.dart'; import '/domain/repository/call.dart'; import '/domain/repository/chat.dart'; import '/domain/repository/user.dart'; +import '/provider/drift/chat_item.dart'; import '/provider/gql/exceptions.dart' show ConnectionException, @@ -55,7 +56,6 @@ import '/provider/gql/exceptions.dart' UploadAttachmentException; import '/provider/gql/graphql.dart'; import '/provider/hive/chat.dart'; -import '/provider/hive/chat_item.dart'; import '/provider/hive/chat_member.dart'; import '/provider/hive/draft.dart'; import '/provider/hive/favorite_chat.dart'; @@ -86,6 +86,7 @@ class ChatRepository extends DisposableInterface ChatRepository( this._graphQlProvider, this._chatLocal, + this._driftItems, this._recentLocal, this._favoriteLocal, this._callRepo, @@ -118,6 +119,9 @@ class ChatRepository extends DisposableInterface /// [Chat]s local [Hive] storage. final ChatHiveProvider _chatLocal; + /// [ChatItem]s local [DriftProvider] storage. + final ChatItemDriftProvider _driftItems; + /// [ChatId]s sorted by [PreciseDateTime] representing recent [Chat]s [Hive] /// storage. final RecentChatHiveProvider _recentLocal; @@ -324,7 +328,13 @@ class ChatRepository extends DisposableInterface if (chat == null) { final HiveChat? hiveChat = await _chatLocal.get(id); if (hiveChat != null) { - chat = HiveRxChat(this, _chatLocal, _draftLocal, hiveChat); + chat = HiveRxChat( + this, + _chatLocal, + _draftLocal, + _driftItems, + hiveChat, + ); chat!.init(); } @@ -1047,7 +1057,7 @@ class ChatRepository extends DisposableInterface /// Fetches [ChatItem]s of the [Chat] with the provided [id] ordered by their /// posting time with pagination. - Future> messages( + Future> messages( ChatId id, { int? first, ChatItemsCursor? after, @@ -1068,7 +1078,7 @@ class ChatRepository extends DisposableInterface ); return Page( - RxList(query.chat!.items.edges.map((e) => e.toHive()).toList()), + RxList(query.chat!.items.edges.map((e) => e.toDto()).toList()), query.chat!.items.pageInfo.toModel((c) => ChatItemsCursor(c)), ); } @@ -1107,14 +1117,14 @@ class ChatRepository extends DisposableInterface ); } - /// Fetches the [HiveChatItem] with the provided [id]. - Future message(ChatItemId id) async { + /// Fetches the [DtoChatItem] with the provided [id]. + Future message(ChatItemId id) async { Log.debug('message($id)', '$runtimeType'); - return (await _graphQlProvider.chatItem(id)).chatItem?.toHive(); + return (await _graphQlProvider.chatItem(id)).chatItem?.toDto(); } /// Fetches the [Attachment]s of the provided [item]. - Future> attachments(HiveChatItem item) async { + Future> attachments(DtoChatItem item) async { Log.debug('attachments($item)', '$runtimeType'); final response = await _graphQlProvider.attachments(item.value.id); @@ -1344,16 +1354,10 @@ class ChatRepository extends DisposableInterface ); } else if (e.$$typename == 'EventChatItemPosted') { var node = e as ChatEventsVersionedMixin$Events$EventChatItemPosted; - return EventChatItemPosted( - e.chatId, - node.item.toHive(), - ); + return EventChatItemPosted(e.chatId, node.item.toDto()); } else if (e.$$typename == 'EventChatLastItemUpdated') { var node = e as ChatEventsVersionedMixin$Events$EventChatLastItemUpdated; - return EventChatLastItemUpdated( - e.chatId, - node.lastItem?.toHive(), - ); + return EventChatLastItemUpdated(e.chatId, node.lastItem?.toDto()); } else if (e.$$typename == 'EventChatItemHidden') { var node = e as ChatEventsVersionedMixin$Events$EventChatItemHidden; return EventChatItemHidden( @@ -1395,7 +1399,7 @@ class ChatRepository extends DisposableInterface node.itemId, node.text == null ? null : EditedMessageText(node.text!.changed), node.attachments?.changed.map((e) => e.toModel()).toList(), - node.repliesTo?.changed.map((e) => e.toHive()).toList(), + node.repliesTo?.changed.map((e) => e.toDto()).toList(), ); } else if (e.$$typename == 'EventChatCallStarted') { var node = e as ChatEventsVersionedMixin$Events$EventChatCallStarted; @@ -1624,7 +1628,13 @@ class ChatRepository extends DisposableInterface HiveRxChat? entry = chats[chatId]; if (entry == null) { - entry = HiveRxChat(this, _chatLocal, _draftLocal, chat)..init(); + entry = HiveRxChat( + this, + _chatLocal, + _draftLocal, + _driftItems, + chat, + )..init(); chats[chatId] = entry; } else { if (entry.chat.value.isMonolog) { @@ -1881,7 +1891,7 @@ class ChatRepository extends DisposableInterface Log.debug('_initRemotePagination()', '$runtimeType'); - Pagination calls = Pagination( + final Pagination calls = Pagination( onKey: (e) => e.value.id, perPage: 15, provider: GraphQlPageProvider( @@ -1896,7 +1906,8 @@ class ChatRepository extends DisposableInterface compare: (a, b) => a.value.compareTo(b.value), ); - Pagination favorites = Pagination( + final Pagination favorites = + Pagination( onKey: (e) => e.value.id, perPage: 15, provider: HiveGraphQlPageProvider( @@ -1931,7 +1942,7 @@ class ChatRepository extends DisposableInterface compare: (a, b) => a.value.compareTo(b.value), ); - Pagination recent = Pagination( + final Pagination recent = Pagination( onKey: (e) => e.value.id, perPage: 15, provider: GraphQlPageProvider( @@ -2188,11 +2199,9 @@ class ChatRepository extends DisposableInterface ignoreVersion: ignoreVersion, ); - for (var item in [ - if (data.lastItem != null) data.lastItem!, - if (data.lastReadItem != null) data.lastReadItem!, - ]) { - entry.put(item); + if (data.lastReadItem != null) { + entry.put(data.lastReadItem!, ignoreBounds: true); + entry.put(data.lastItem!); } _putEntryGuards.remove(chatId); @@ -2206,10 +2215,7 @@ class ChatRepository extends DisposableInterface RecentChatsCursor? recentCursor, FavoriteChatsCursor? favoriteCursor, }) { - Log.trace( - '_chat($q, $recentCursor, $favoriteCursor)', - '$runtimeType', - ); + Log.trace('_chat($q, $recentCursor, $favoriteCursor)', '$runtimeType'); for (var m in q.members.nodes) { _userRepo.put(m.user.toDto()); @@ -2442,11 +2448,11 @@ class ChatData { /// [HiveChat] returned from the [Chat] fetching. final HiveChat chat; - /// [HiveChatItem] of a [Chat.lastItem] returned from the [Chat] fetching. - final HiveChatItem? lastItem; + /// [DtoChatItem] of a [Chat.lastItem] returned from the [Chat] fetching. + final DtoChatItem? lastItem; - /// [HiveChatItem] of a [Chat.lastReadItem] returned from the [Chat] fetching. - final HiveChatItem? lastReadItem; + /// [DtoChatItem] of a [Chat.lastReadItem] returned from the [Chat] fetching. + final DtoChatItem? lastReadItem; @override String toString() => diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index bcb73a04b19..af18d5e4ceb 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -43,12 +43,11 @@ import '/domain/model/user_call_cover.dart'; import '/domain/repository/chat.dart'; import '/domain/repository/paginated.dart'; import '/domain/repository/user.dart'; +import '/provider/drift/chat_item.dart'; import '/provider/gql/exceptions.dart' show ConnectionException, PostChatMessageException, StaleVersionException; import '/provider/hive/base.dart'; import '/provider/hive/chat.dart'; -import '/provider/hive/chat_item.dart'; -import '/provider/hive/chat_item_sorting.dart'; import '/provider/hive/chat_member.dart'; import '/provider/hive/chat_member_sorting.dart'; import '/provider/hive/draft.dart'; @@ -68,10 +67,12 @@ import '/util/web/web_utils.dart'; import 'chat.dart'; import 'event/chat.dart'; import 'paginated.dart'; +import 'pagination/drift.dart'; +import 'pagination/drift_graphql.dart'; import 'pagination/graphql.dart'; typedef MessagesPaginated - = RxPaginatedImpl, HiveChatItem, ChatItemsCursor>; + = RxPaginatedImpl, DtoChatItem, ChatItemsCursor>; typedef MembersPaginated = RxPaginatedImpl; @@ -82,11 +83,10 @@ class HiveRxChat extends RxChat { this._chatRepository, this._chatLocal, this._draftLocal, + this._driftItems, HiveChat hiveChat, ) : chat = Rx(hiveChat.value), _lastReadItemCursor = hiveChat.lastReadItemCursor, - _local = ChatItemHiveProvider(hiveChat.value.id), - _sorting = ChatItemSortingHiveProvider(hiveChat.value.id), _membersLocal = ChatMemberHiveProvider(hiveChat.value.id), _membersSorting = ChatMemberSortingHiveProvider(hiveChat.value.id), draft = Rx(_draftLocal.get(hiveChat.value.id)), @@ -136,12 +136,6 @@ class HiveRxChat extends RxChat { /// [RxChat.draft]s local [Hive] storage. final DraftHiveProvider _draftLocal; - /// [ChatItem]s local [Hive] storage. - ChatItemHiveProvider _local; - - /// [Hive] storage for [ChatItemId]s sorted by [ChatItem.at]. - ChatItemSortingHiveProvider _sorting; - /// [ChatMember]s local [Hive] storage. final ChatMemberHiveProvider _membersLocal; @@ -149,8 +143,11 @@ class HiveRxChat extends RxChat { /// [ChatMember]s from [_membersLocal]. final ChatMemberSortingHiveProvider _membersSorting; + /// [ChatItem]s local storage. + final ChatItemDriftProvider _driftItems; + /// [Pagination] loading [messages] with pagination. - late final Pagination _pagination; + late final Pagination _pagination; /// [MessagesPaginated]s created by this [HiveRxChat]. final List _fragments = []; @@ -159,10 +156,6 @@ class HiveRxChat extends RxChat { /// [reads]. final List _fragmentSubscriptions = []; - /// [PageProvider] fetching pages of [HiveChatItem]s. - late final HiveGraphQlPageProvider - _provider; - /// [Worker] reacting on the [User] changes updating the [avatar]. Worker? _userWorker; @@ -435,8 +428,6 @@ class HiveRxChat extends RxChat { _callSubscription?.cancel(); _membersSubscription?.cancel(); _membersPaginationSubscription?.cancel(); - await _local.close(); - await _sorting.close(); status.value = RxStatus.empty(); _worker?.dispose(); _userWorker?.dispose(); @@ -512,9 +503,7 @@ class HiveRxChat extends RxChat { return _paginateAround(item, reply: reply, forward: forward); } - if (id.isLocal || - status.value.isSuccess || - (hasNext.isFalse && hasPrevious.isFalse)) { + if (id.isLocal || (hasNext.isFalse && hasPrevious.isFalse)) { return null; } @@ -522,10 +511,6 @@ class HiveRxChat extends RxChat { status.value = RxStatus.loadingMore(); } - // Ensure [_local] and [_sorting] storages are initialized. - await _local.init(userId: me); - await _sorting.init(userId: me); - // TODO: Perhaps the [messages] should be in a [MessagesPaginated] as well? // This will make it easy to dispose the messages, when they aren't // needed, so that RAM is freed. @@ -672,7 +657,7 @@ class HiveRxChat extends RxChat { '$runtimeType($id)', ); - HiveChatMessage message = HiveChatMessage.sending( + DtoChatMessage message = DtoChatMessage.sending( chatId: chat.value.id, me: me!, text: text, @@ -755,10 +740,10 @@ class HiveRxChat extends RxChat { .firstWhereOrNull((e) => e is EventChatItemPosted) as EventChatItemPosted?; - if (event != null && event.item is HiveChatMessage) { + if (event != null && event.item is DtoChatMessage) { remove(message.value.id); _pending.remove(message.value); - message = event.item as HiveChatMessage; + message = event.item as DtoChatMessage; if (text?.val.startsWith('/') == false && text?.val.startsWith('[@bot]') == false && @@ -810,17 +795,13 @@ class HiveRxChat extends RxChat { } /// Adds the provided [item] to the [Pagination]s. - Future put(HiveChatItem item, {bool ignoreBounds = false}) async { + Future put(DtoChatItem item, {bool ignoreBounds = false}) async { Log.debug('put($item)', '$runtimeType($id)'); + await _pagination.put(item, ignoreBounds: ignoreBounds); for (var e in _fragments) { await e.pagination?.put(item, ignoreBounds: ignoreBounds); } - - // Put [item] to the [_local] storage if it's not stored there yet. - if (!_local.keys.contains(item.value.id)) { - await _local.put(item); - } } @override @@ -831,9 +812,9 @@ class HiveRxChat extends RxChat { for (var e in _fragments) { e.pagination?.remove(itemId); } - for (var e in _sorting.keys.where((e) => e.id == itemId)) { - _sorting.remove(e); - } + // for (var e in _sorting.keys.where((e) => e.id == itemId)) { + // _sorting.remove(e); + // } _chatLocal.txn((txn) async { final HiveChat? chatEntity = await txn.get(id.val); @@ -843,7 +824,7 @@ class HiveRxChat extends RxChat { if (lastItem != null) { chatEntity?.value.lastItem = lastItem.value; chatEntity?.lastItemCursor = - (await _local.get(lastItem.value.id))?.cursor; + (await _driftItems.read(lastItem.value.id))?.cursor; } else { chatEntity?.value.lastItem = null; chatEntity?.lastItemCursor = null; @@ -854,17 +835,17 @@ class HiveRxChat extends RxChat { }); } - /// Returns the stored or fetched [HiveChatItem] identified by the provided + /// Returns the stored or fetched [DtoChatItem] identified by the provided /// [itemId]. - Future get(ChatItemId itemId) async { + Future get(ChatItemId itemId) async { Log.debug('get($itemId)', '$runtimeType($id)'); - HiveChatItem? item = _pagination.items[itemId]; + DtoChatItem? item = _pagination.items[itemId]; item ??= _fragments .firstWhereOrNull((e) => e.pagination?.items[itemId] != null) ?.pagination ?.items[itemId]; - item ??= await _local.get(itemId); + item ??= await _driftItems.read(itemId); if (item == null) { try { @@ -914,30 +895,15 @@ class HiveRxChat extends RxChat { _initRemoteSubscription(); } - // Retrieve all the [HiveChatItem]s to put them in the [newChat]. - final Iterable saved = await _local.values; - - final local = ChatItemHiveProvider(id); - await local.init(userId: me); - - final sorting = ChatItemSortingHiveProvider(id); - await sorting.init(userId: me); - - // Clear and close the current [Hive] providers. - await _local.clear(); - await _sorting.clear(); - _local.close(); - _sorting.close(); + // Retrieve all the [DtoChatItem]s to put them in the [newChat]. + final Iterable saved = + _pagination.items.values.toList(growable: false); await clear(); - _local = local; - _sorting = sorting; - _provider.hive = _local; - - for (var e in saved.whereType()) { - // Copy the [HiveChatMessage] to the new [ChatItemHiveProvider]. - final HiveChatMessage copy = e.copyWith() + for (var e in saved.whereType()) { + // Copy the [DtoChatMessage] to the new [ChatItemHiveProvider]. + final DtoChatMessage copy = e.copyWith() ..value.chatId = newChat.value.id; if (copy.value.status.value == SendingStatus.error) { @@ -962,9 +928,6 @@ class HiveRxChat extends RxChat { await _pagination.clear(); - await _local.clear(); - await _sorting.clear(); - // [Chat.members] don't change in dialogs or monologs, no need to clear it. if (chat.value.isGroup) { await members.clear(); @@ -977,7 +940,7 @@ class HiveRxChat extends RxChat { Log.debug('addMessage($text)', '$runtimeType($id)'); await put( - HiveChatMessage( + DtoChatMessage( ChatMessage( ChatItemId.local(), id, @@ -1083,59 +1046,83 @@ class HiveRxChat extends RxChat { /// Initializes the messages [_pagination]. Future _initMessagesPagination() async { - _provider = HiveGraphQlPageProvider( - graphQlProvider: GraphQlPageProvider( - reversed: true, - fetch: ({after, before, first, last}) async { - final Page reversed = - await _chatRepository.messages( - chat.value.id, - after: after, - first: first, - before: before, - last: last, - ); + _pagination = Pagination( + onKey: (e) => e.value.id, + provider: DriftGraphQlPageProvider( + graphQlProvider: GraphQlPageProvider( + reversed: true, + fetch: ({after, before, first, last}) async { + final Page reversed = + await _chatRepository.messages( + chat.value.id, + after: after, + first: first, + before: before, + last: last, + ); - final Page page; - if (_provider.graphQlProvider.reversed) { - page = reversed.reversed(); - } else { - page = reversed; - } + final Page page = reversed.reversed(); - if (page.info.hasPrevious == false) { - _chatLocal.txn((txn) async { - final HiveChat? chatEntity = await txn.get(id.val); - final ChatItem? firstItem = page.edges.firstOrNull?.value; + if (page.info.hasPrevious == false) { + // [PageInfo.hasPrevious] is `false`, when querying `before` only. + if (before == null || after != null) { + _chatLocal.txn((txn) async { + final HiveChat? chatEntity = await txn.get(id.val); + final ChatItem? firstItem = page.edges.firstOrNull?.value; - if (chatEntity != null && - firstItem != null && - chatEntity.value.firstItem != firstItem) { - chatEntity.value.firstItem = firstItem; - await _putChat(chatEntity, txn); + if (chatEntity != null && + firstItem != null && + chatEntity.value.firstItem != firstItem) { + chatEntity.value.firstItem = firstItem; + await _putChat(chatEntity, txn); + } + }); } - }); - } + } - return reversed; - }, - ), - hiveProvider: HivePageProvider( - _local, - getCursor: (e) => e?.cursor, - getKey: (e) => e.value.id, - orderBy: (_) => _sorting.values, - isFirst: (e) => - id.isLocal || (e != null && chat.value.firstItem?.id == e.value.id), - isLast: (e) => - id.isLocal || (e != null && chat.value.lastItem?.id == e.value.id), - strategy: PaginationStrategy.fromEnd, - ), - ); + return reversed; + }, + ), + driftProvider: DriftPageProvider( + fetch: ({required after, required before, ChatItemId? around}) async { + PreciseDateTime? at; - _pagination = Pagination( - onKey: (e) => e.value.id, - provider: _provider, + if (around != null) { + final DtoChatItem? item = await get(around); + at = item?.value.at; + } + + return await _driftItems.view( + id, + before: before, + after: after, + around: at, + ); + }, + onKey: (e) => e.value.id, + onCursor: (e) => e?.cursor, + add: (e, {bool toView = true}) async => + await _driftItems.upsertBulk(e, toView: toView), + delete: (e) async => await _driftItems.delete(e), + reset: () async => await _driftItems.clear(), + isFirst: (e) { + if (e.value.id.isLocal) { + return null; + } + + return chat.value.firstItem?.id == e.value.id; + }, + isLast: (e) { + if (e.value.id.isLocal) { + return null; + } + + return chat.value.lastItem?.id == e.value.id; + }, + onNone: (k) async => await _driftItems.upsertView(id, k), + compare: (a, b) => a.value.key.compareTo(b.value.key), + ), + ), compare: (a, b) => a.value.key.compareTo(b.value.key), ); @@ -1151,10 +1138,6 @@ class HiveRxChat extends RxChat { case OperationKind.updated: final ChatItem item = event.value!.value; _add(item); - - if (!_sorting.keys.contains(item.key)) { - _sorting.put(item.key); - } break; case OperationKind.removed: @@ -1163,16 +1146,15 @@ class HiveRxChat extends RxChat { } }); - await _local.init(userId: me); - await _sorting.init(userId: me); + DtoChatItem? item; - HiveChatItem? item; - - // If [_local] storage is empty, then there's no need to initialize the - // [_pagination], as it fetches its items from [_local] by this method. - if (chat.value.lastReadItem != null && _local.keys.isNotEmpty) { + if (chat.value.lastReadItem != null) { item = await get(chat.value.lastReadItem!); - await _pagination.init(item?.value.id); + await _pagination.init( + chat.value.lastReadItem == chat.value.lastItem?.id + ? null + : item?.value.id, + ); } if (_pagination.items.isNotEmpty) { @@ -1276,7 +1258,7 @@ class HiveRxChat extends RxChat { Log.debug('_paginateAround($item, $reply, $forward)', '$runtimeType($id)'); // Retrieve the [item] itself pointed around. - final HiveChatItem? hiveItem = await get(item); + final DtoChatItem? dto = await get(item); final ChatItemsCursor? cursor; final ChatItemId key = forward ?? reply ?? item; @@ -1284,7 +1266,7 @@ class HiveRxChat extends RxChat { // If [reply] or [forward] is provided, then the [item] should contain it, // let's try to retrieve the key and cursor to paginate around it. if (reply != null) { - if (hiveItem is! HiveChatMessage) { + if (dto is! DtoChatMessage) { throw ArgumentError.value( item, 'item', @@ -1292,16 +1274,16 @@ class HiveRxChat extends RxChat { ); } - final ChatMessage message = hiveItem.value as ChatMessage; + final ChatMessage message = dto.value as ChatMessage; final int replyIndex = message.repliesTo.indexWhere((e) => e.original?.id == reply); if (replyIndex == -1) { throw ArgumentError.value(reply, 'reply', 'Not found.'); } - cursor = hiveItem.repliesToCursors?.elementAt(replyIndex); + cursor = dto.repliesToCursors?.elementAt(replyIndex); } else if (forward != null) { - if (hiveItem is! HiveChatForward) { + if (dto is! DtoChatForward) { throw ArgumentError.value( item, 'item', @@ -1309,9 +1291,9 @@ class HiveRxChat extends RxChat { ); } - cursor = hiveItem.quoteCursor; + cursor = dto.quoteCursor; } else { - cursor = hiveItem?.cursor; + cursor = dto?.cursor; } // Try to find any [MessagesPaginated] already containing the item requested. @@ -1337,7 +1319,7 @@ class HiveRxChat extends RxChat { fragment = MessagesPaginated( initialKey: key, initialCursor: cursor, - transform: ({required HiveChatItem data, Rx? previous}) { + transform: ({required DtoChatItem data, Rx? previous}) { if (previous != null) { return previous..value = data.value; } @@ -1346,7 +1328,21 @@ class HiveRxChat extends RxChat { }, pagination: Pagination( onKey: (e) => e.value.id, - provider: _provider, + provider: GraphQlPageProvider( + reversed: true, + fetch: ({after, before, first, last}) async { + final Page reversed = + await _chatRepository.messages( + chat.value.id, + after: after ?? before, + first: first, + before: before ?? after, + last: last, + ); + + return reversed; + }, + ), perPage: perPage, compare: (a, b) => a.value.key.compareTo(b.value.key), ), @@ -1514,7 +1510,7 @@ class HiveRxChat extends RxChat { Future _updateAttachments(ChatItem item) async { Log.debug('_updateAttachments($item)', '$runtimeType($id)'); - final HiveChatItem? stored = await get(item.id); + final DtoChatItem? stored = await get(item.id); if (stored != null) { final List response = await _chatRepository.attachments(stored); @@ -1746,7 +1742,7 @@ class HiveRxChat extends RxChat { message.repliesTo = event.quotes?.map((e) => e.value).toList() ?? message.repliesTo; - (item as HiveChatMessage).repliesToCursors = + (item as DtoChatMessage).repliesToCursors = event.quotes?.map((e) => e.cursor).toList() ?? item.repliesToCursors; put(item); @@ -1909,19 +1905,6 @@ class HiveRxChat extends RxChat { } else { read.at = at; } - - if (event.byUser.id == me) { - final ChatItemKey? key = - _sorting.keys.lastWhereOrNull((e) => e.at == at); - if (key != null) { - final HiveChatItem? item = await _local.get(key.id); - if (item != null) { - chatEntity.lastReadItemCursor = item.cursor!; - chatEntity.value.lastReadItem = item.value.id; - _lastReadItemCursor = item.cursor!; - } - } - } } LastChatRead? lastRead = chatEntity.value.lastReads @@ -1940,7 +1923,7 @@ class HiveRxChat extends RxChat { case ChatEventKind.itemPosted: event as EventChatItemPosted; - final HiveChatItem item = event.item; + final DtoChatItem item = event.item; if (chatEntity.value.isHidden) { chatEntity.value.isHidden = false; @@ -1958,7 +1941,7 @@ class HiveRxChat extends RxChat { // one is found in the [_pending] messages, and this message // is not yet added to the store, then remove the [pending]. if (pending != null && - await _local.get(item.value.id) == null) { + await _driftItems.read(item.value.id) == null) { remove(pending.id); _pending.remove(pending); } @@ -2029,7 +2012,7 @@ class HiveRxChat extends RxChat { } } - if (item is HiveChatMessage) { + if (item is DtoChatMessage) { final msg = item.value as ChatMessage; if (bots.isEmpty) { diff --git a/lib/store/event/chat.dart b/lib/store/event/chat.dart index 1da4edcca4a..845fe3127af 100644 --- a/lib/store/event/chat.dart +++ b/lib/store/event/chat.dart @@ -17,14 +17,14 @@ import '/api/backend/schema.dart' show ChatCallFinishReason; import '/domain/model/attachment.dart'; -import '/domain/model/chat.dart'; import '/domain/model/chat_call.dart'; import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; import '/domain/model/mute_duration.dart'; import '/domain/model/precise_date_time/precise_date_time.dart'; import '/domain/model/user.dart'; import '/provider/hive/chat.dart'; -import '/provider/hive/chat_item.dart'; +import '/store/model/chat_item.dart'; import '/store/model/chat.dart'; /// Possible kinds of a [ChatEvent]. @@ -305,8 +305,8 @@ class EventChatItemEdited extends ChatEvent { /// Edited [Attachment]s of the [ChatItem]. final List? attachments; - /// [HiveChatItemQuote]s the edited [ChatItem] replies to. - final List? quotes; + /// [DtoChatItemQuote]s the edited [ChatItem] replies to. + final List? quotes; @override ChatEventKind get kind => ChatEventKind.itemEdited; @@ -395,7 +395,7 @@ class EventChatLastItemUpdated extends ChatEvent { const EventChatLastItemUpdated(super.chatId, this.lastItem); /// Updated last [ChatItem]. - final HiveChatItem? lastItem; + final DtoChatItem? lastItem; @override ChatEventKind get kind => ChatEventKind.lastItemUpdated; @@ -453,7 +453,7 @@ class EventChatItemPosted extends ChatEvent { const EventChatItemPosted(super.chatId, this.item); /// New [ChatItem]. - final HiveChatItem item; + final DtoChatItem item; @override ChatEventKind get kind => ChatEventKind.itemPosted; diff --git a/lib/store/model/chat_call.dart b/lib/store/model/chat_call.dart index 0e4e4e6aaf9..5c30337dba4 100644 --- a/lib/store/model/chat_call.dart +++ b/lib/store/model/chat_call.dart @@ -16,12 +16,31 @@ // . import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '/domain/model_type_id.dart'; +import '/domain/model/chat_item.dart'; import '/util/new_type.dart'; +import 'chat_item.dart'; part 'chat_call.g.dart'; +/// Persisted in storage [ChatCall]'s [value]. +@JsonSerializable() +@HiveType(typeId: ModelTypeId.dtoChatCall) +class DtoChatCall extends DtoChatItem { + DtoChatCall(super.value, super.cursor, super.ver); + + /// Constructs a [DtoChatCall] from the provided [json]. + factory DtoChatCall.fromJson(Map json) => + _$DtoChatCallFromJson(json); + + /// Returns a [Map] representing this [DtoChatCall]. + @override + Map toJson() => + _$DtoChatCallToJson(this)..['runtimeType'] = 'DtoChatCall'; +} + /// Cursor of an [OngoingCall] position. @HiveType(typeId: ModelTypeId.incomingChatCallsCursor) class IncomingChatCallsCursor extends NewType { diff --git a/lib/store/model/chat_item.dart b/lib/store/model/chat_item.dart index 28fe8371ad9..fd3e8dc504c 100644 --- a/lib/store/model/chat_item.dart +++ b/lib/store/model/chat_item.dart @@ -16,21 +16,204 @@ // . import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; import '/domain/model_type_id.dart'; +import '/domain/model/attachment.dart'; +import '/domain/model/chat_item_quote.dart'; +import '/domain/model/chat_item.dart'; +import '/domain/model/chat.dart'; +import '/domain/model/precise_date_time/precise_date_time.dart'; +import '/domain/model/sending_status.dart'; +import '/domain/model/user.dart'; import '/util/new_type.dart'; +import 'chat_call.dart'; import 'version.dart'; part 'chat_item.g.dart'; +/// Persisted in storage [ChatItem]'s [value]. +abstract class DtoChatItem extends HiveObject { + DtoChatItem(this.value, this.cursor, this.ver); + + /// Constructs a [DtoChatItem] from the provided [json]. + factory DtoChatItem.fromJson(Map json) => + switch (json['runtimeType']) { + 'DtoChatMessage' => DtoChatMessage.fromJson(json), + 'DtoChatCall' => DtoChatCall.fromJson(json), + 'DtoChatInfo' => DtoChatInfo.fromJson(json), + 'DtoChatForward' => DtoChatForward.fromJson(json), + _ => throw UnimplementedError(json['runtimeType']) + }; + + /// Persisted [ChatItem] model. + @HiveField(0) + ChatItem value; + + /// Cursor of a [ChatItem] this [DtoChatItem] represents. + @HiveField(1) + ChatItemsCursor? cursor; + + /// Version of a [ChatItem]'s state. + /// + /// It increases monotonically, so may be used (and is intended to) for + /// tracking state's actuality. + @HiveField(2) + final ChatItemVersion ver; + + @override + String toString() => '$runtimeType($value, $cursor, $ver)'; + + /// Returns a [Map] representing this [DtoChatItem]. + Map toJson() => switch (runtimeType) { + const (DtoChatMessage) => (this as DtoChatMessage).toJson(), + const (DtoChatCall) => (this as DtoChatCall).toJson(), + const (DtoChatInfo) => (this as DtoChatInfo).toJson(), + const (DtoChatForward) => (this as DtoChatForward).toJson(), + _ => throw UnimplementedError(runtimeType.toString()), + }; +} + +/// Persisted in storage [ChatInfo]'s [value]. +@JsonSerializable() +@HiveType(typeId: ModelTypeId.dtoChatInfo) +class DtoChatInfo extends DtoChatItem { + DtoChatInfo(super.value, super.cursor, super.ver); + + /// Constructs a [DtoChatCall] from the provided [json]. + factory DtoChatInfo.fromJson(Map json) => + _$DtoChatInfoFromJson(json); + + /// Returns a [Map] representing this [DtoChatInfo]. + @override + Map toJson() => + _$DtoChatInfoToJson(this)..['runtimeType'] = 'DtoChatInfo'; +} + +/// Persisted in storage [ChatMessage]'s [value]. +@JsonSerializable() +@HiveType(typeId: ModelTypeId.dtoChatMessage) +class DtoChatMessage extends DtoChatItem { + DtoChatMessage( + super.value, + super.cursor, + super.ver, + this.repliesToCursors, + ); + + /// Constructs a [DtoChatMessage] in a [SendingStatus.sending] state. + factory DtoChatMessage.sending({ + required ChatId chatId, + required UserId me, + ChatMessageText? text, + List repliesTo = const [], + List attachments = const [], + ChatItemId? existingId, + PreciseDateTime? existingDateTime, + }) => + DtoChatMessage( + ChatMessage( + existingId ?? ChatItemId.local(), + chatId, + User(me, UserNum('1234123412341234')), + existingDateTime ?? PreciseDateTime.now(), + text: text, + repliesTo: repliesTo, + attachments: attachments, + status: SendingStatus.sending, + ), + null, + ChatItemVersion('0'), + [], + ); + + /// Constructs a [DtoChatMessage] from the provided [json]. + factory DtoChatMessage.fromJson(Map json) => + _$DtoChatMessageFromJson(json); + + /// Cursors of the [ChatMessage.repliesTo] list. + @HiveField(3) + List? repliesToCursors; + + /// Returns a copy of this [DtoChatMessage] with the provided parameters. + DtoChatMessage copyWith({ + ChatItem? value, + ChatItemsCursor? cursor, + ChatItemVersion? ver, + List? repliesToCursors, + }) { + return DtoChatMessage( + value ?? this.value, + cursor ?? this.cursor, + ver ?? this.ver, + repliesToCursors ?? this.repliesToCursors, + ); + } + + /// Returns a [Map] representing this [DtoChatMessage]. + @override + Map toJson() => + _$DtoChatMessageToJson(this)..['runtimeType'] = 'DtoChatMessage'; +} + +/// Persisted in storage [ChatForward]'s [value]. +@JsonSerializable() +@HiveType(typeId: ModelTypeId.dtoChatForward) +class DtoChatForward extends DtoChatItem { + DtoChatForward( + super.value, + super.cursor, + super.ver, + this.quoteCursor, + ); + + /// Constructs a [DtoChatForward] from the provided [json]. + factory DtoChatForward.fromJson(Map json) => + _$DtoChatForwardFromJson(json); + + /// Cursor of a [ChatForward.quote]. + @HiveField(3) + ChatItemsCursor? quoteCursor; + + /// Returns a [Map] representing this [DtoChatForward]. + @override + Map toJson() => + _$DtoChatForwardToJson(this)..['runtimeType'] = 'DtoChatForward'; +} + +/// Persisted in storage [ChatItemQuote]'s [value]. +class DtoChatItemQuote { + DtoChatItemQuote(this.value, this.cursor); + + /// [ChatItemQuote] itself. + @HiveField(0) + final ChatItemQuote value; + + /// Cursor of a [ChatItemQuote.original]. + @HiveField(1) + ChatItemsCursor? cursor; +} + /// Version of a [ChatItem]'s state. @HiveType(typeId: ModelTypeId.chatItemVersion) class ChatItemVersion extends Version { ChatItemVersion(super.val); + + /// Constructs a [ChatItemVersion] from the provided [val]. + factory ChatItemVersion.fromJson(String val) = ChatItemVersion; + + /// Returns a [String] representing this [ChatItemVersion]. + String toJson() => val; } /// Cursor of a [ChatItem]. @HiveType(typeId: ModelTypeId.chatItemsCursor) class ChatItemsCursor extends NewType { const ChatItemsCursor(super.val); + + /// Constructs a [ChatItemsCursor] from the provided [val]. + factory ChatItemsCursor.fromJson(String val) = ChatItemsCursor; + + /// Returns a [String] representing this [ChatItemsCursor]. + String toJson() => val; } diff --git a/lib/store/pagination.dart b/lib/store/pagination.dart index dfc1b341259..28dfb8856a3 100644 --- a/lib/store/pagination.dart +++ b/lib/store/pagination.dart @@ -161,7 +161,7 @@ class Pagination { }); } - /// Fetches the [Page] around the provided [item] or [cursor]. + /// Fetches the [Page] around the provided [key] or [cursor]. /// /// If neither [item] nor [cursor] is provided, then fetches the first [Page]. Future?> around({K? key, C? cursor}) { @@ -356,7 +356,7 @@ class Pagination { items[onKey(item)] = item; } - await provider.put(item, compare: put ? null : compare); + await provider.put([item], compare: put ? null : compare); } /// Removes the item with the provided [key] from the [items] and [provider]. @@ -414,19 +414,19 @@ abstract class PageProvider { /// Initializes this [PageProvider], loading initial [Page], if any. Future?> init(K? key, int count); - /// Fetches the [Page] around the provided [item] or [cursor]. + /// Fetches the [Page] around the provided [key] or [cursor]. /// - /// If neither [item] nor [cursor] is provided, then fetches the first [Page]. + /// If neither [key] nor [cursor] is provided, then fetches the first [Page]. FutureOr?> around(K? key, C? cursor, int count); - /// Fetches the [Page] after the provided [item] or [cursor]. + /// Fetches the [Page] after the provided [key] or [cursor]. FutureOr?> after(K? key, C? cursor, int count); - /// Fetches the [Page] before the provided [item] or [cursor]. + /// Fetches the [Page] before the provided [key] or [cursor]. FutureOr?> before(K? key, C? cursor, int count); - /// Adds the provided [item] to this [PageProvider]. - Future put(T item, {int Function(T, T)? compare}); + /// Adds the provided [items] to this [PageProvider]. + Future put(Iterable items, {int Function(T, T)? compare}); /// Removes the item specified by its [key] from this [PageProvider]. Future remove(K key); diff --git a/lib/store/pagination/drift.dart b/lib/store/pagination/drift.dart new file mode 100644 index 00000000000..9846ce636ec --- /dev/null +++ b/lib/store/pagination/drift.dart @@ -0,0 +1,237 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:log_me/log_me.dart'; + +import '/store/chat_rx.dart'; +import '/store/model/page_info.dart'; +import '/store/pagination.dart'; + +/// [PageProvider] fetching items from the [DriftProvider]. +class DriftPageProvider extends PageProvider { + DriftPageProvider({ + required this.onKey, + required this.onCursor, + required this.fetch, + this.add, + this.delete, + this.reset, + this.isFirst, + this.isLast, + this.onNone, + this.compare, + }); + + /// Callback, called when a [K] of the provided [T] is required. + final K Function(T) onKey; + + /// Callback, called when a cursor of the provided [T] is required. + final C? Function(T?) onCursor; + + /// Callback, called when the [after] and [before] amounts of [T] items + /// [around] the provided [K] are required. + final FutureOr> Function({ + required int after, + required int before, + K? around, + }) fetch; + + /// Callback, called when the provided [items] should be persisted. + final Future Function(Iterable items, {bool toView})? add; + + /// Callback, called when the provided [key] was invoked during [init]. + final Future Function(K key)? onNone; + + /// Callback, called when an item at the [key] should be deleted. + final Future Function(K key)? delete; + + /// Callback, called when this provider should clear all its data. + final Future Function()? reset; + + /// Callback, called to indicate whether the provided [T] is the first. + /// + /// `null` returned means that the [T] shouldn't participant in such test. + final bool? Function(T)? isFirst; + + /// Callback, called to indicate whether the provided [T] is the last. + /// + /// `null` returned means that the [T] shouldn't participant in such test. + final bool? Function(T)? isLast; + + /// Callback, called to compare the provided [T] items. + final int Function(T, T)? compare; + + /// Internal [List] of [T] items retrieved from the [fetch]. + List _list = []; + + /// Count of [T] items requested after the [_around]. + int _after = 0; + + /// Count of [T] items requested before the [_around]. + int _before = 0; + + /// Key [K], around which the [_list] should be [fetch]ed. + K? _around; + + /// Indicates whether the [_list] contain an item identified as the first. + bool get _hasFirst => + _list.lastWhereOrNull((e) => isFirst?.call(e) == true) != null; + + /// Indicates whether the [_list] contain an item identified as the last. + bool get _hasLast => + _list.firstWhereOrNull((e) => isLast?.call(e) == true) != null; + + @override + Future> init(K? key, int count) async { + _reset(around: key, count: count); + + final List edges = await _page(); + + Log.debug( + 'init($key, $count) -> (${edges.length}), hasNext: ${!_hasLast}, hasPrevious: ${!_hasFirst}', + ); + + if (edges.isEmpty && key != null) { + await onNone?.call(key); + } + + return Page( + edges, + PageInfo( + hasNext: !_hasLast, + hasPrevious: !_hasFirst, + startCursor: onCursor(edges.lastOrNull), + endCursor: onCursor(edges.firstOrNull), + ), + ); + } + + @override + Future> around(K? key, C? cursor, int count) async { + _reset(around: key, count: count); + + final int edgesBefore = _list.length; + final List edges = await _page(); + final bool fulfilled = + (_hasFirst && _hasLast) || edges.length - edgesBefore >= count ~/ 2; + + Log.debug( + 'around($key, $count) -> $fulfilled(${edges.length}), hasNext: ${!_hasLast}, hasPrevious: ${!_hasFirst}', + ); + + return Page( + fulfilled ? edges : [], + PageInfo( + hasNext: !_hasLast, + hasPrevious: !_hasFirst, + startCursor: onCursor(edges.lastOrNull), + endCursor: onCursor(edges.firstOrNull), + ), + ); + } + + @override + Future> after(K? key, C? cursor, int count) async { + _after += count; + + final int edgesBefore = _list.length; + final List edges = await _page(); + final bool fulfilled = _hasLast || edges.length - edgesBefore >= count; + + Log.debug( + 'after($key, $count) -> $fulfilled(${edges.length}), hasNext: ${!_hasLast}, hasPrevious: ${!_hasFirst}', + ); + + return Page( + fulfilled ? edges : [], + PageInfo( + hasNext: !_hasLast, + hasPrevious: !_hasFirst, + startCursor: onCursor(edges.lastOrNull), + endCursor: onCursor(edges.firstOrNull), + ), + ); + } + + @override + Future> before(K? key, C? cursor, int count) async { + _before += count; + + final int edgesBefore = _list.length; + final List edges = await _page(); + final bool fulfilled = _hasFirst || edges.length - edgesBefore >= count; + + Log.debug( + 'before($key, $count) -> $fulfilled(${edges.length}), hasNext: ${!_hasLast}, hasPrevious: ${!_hasFirst}', + ); + + return Page( + fulfilled ? edges : [], + PageInfo( + hasNext: !_hasLast, + hasPrevious: !_hasFirst, + startCursor: onCursor(edges.lastOrNull), + endCursor: onCursor(edges.firstOrNull), + ), + ); + } + + @override + Future put(Iterable items, {int Function(T, T)? compare}) async { + final bool toView = compare == null; + + if (toView) { + for (var item in items) { + final int i = _list.indexWhere((e) => onKey(e) == onKey(item)); + if (i != -1) { + _list[i] = item; + } else { + _list.insertAfter(item, (e) => this.compare?.call(e, item) == 1); + } + } + } + + await add?.call(items, toView: toView); + } + + @override + Future remove(K key) async { + await delete?.call(key); + } + + @override + Future clear() async { + await reset?.call(); + } + + /// Resets all the values to be [around]. + void _reset({K? around, int count = 50}) { + _list.clear(); + _after = count ~/ 2; + _before = count ~/ 2; + _around = around; + } + + /// Returns the [T] items [fetch]ed. + Future> _page() async { + _list = await fetch(after: _after, before: _before, around: _around); + return _list; + } +} diff --git a/lib/store/pagination/drift_graphql.dart b/lib/store/pagination/drift_graphql.dart new file mode 100644 index 00000000000..53328341cc2 --- /dev/null +++ b/lib/store/pagination/drift_graphql.dart @@ -0,0 +1,95 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'dart:async'; + +import '/store/pagination.dart'; +import 'drift.dart'; +import 'graphql.dart'; + +/// [DriftPageProvider] and [GraphQlPageProvider] providers combined. +class DriftGraphQlPageProvider + implements PageProvider { + const DriftGraphQlPageProvider({ + required this.driftProvider, + required this.graphQlProvider, + }); + + /// [DriftPageProvider] fetching elements from the [DriftProvider]. + final DriftPageProvider driftProvider; + + /// [GraphQlPageProvider] fetching elements from the remote. + final GraphQlPageProvider graphQlProvider; + + @override + Future> init(K? key, int count) => driftProvider.init(key, count); + + @override + Future> around(K? key, C? cursor, int count) async { + final Page cached = await driftProvider.around(key, cursor, count); + + if (cached.edges.isNotEmpty) { + return cached; + } + + final Page remote = await graphQlProvider.around(key, cursor, count); + + await driftProvider.put(remote.edges); + + return remote; + } + + @override + Future> after(K? key, C? cursor, int count) async { + final Page cached = await driftProvider.after(key, cursor, count); + + if (cached.edges.isNotEmpty) { + return cached; + } + + final Page remote = await graphQlProvider.after(key, cursor, count); + + await driftProvider.put(remote.edges); + + return remote; + } + + @override + Future> before(K? key, C? cursor, int count) async { + final Page cached = await driftProvider.before(key, cursor, count); + + if (cached.edges.isNotEmpty) { + return cached; + } + + final Page remote = await graphQlProvider.before(key, cursor, count); + + await driftProvider.put(remote.edges); + + return remote; + } + + @override + Future put(Iterable items, {int Function(T, T)? compare}) => + driftProvider.put(items, compare: compare); + + @override + Future remove(K key) => driftProvider.remove(key); + + @override + Future clear() => driftProvider.clear(); +} diff --git a/lib/store/pagination/graphql.dart b/lib/store/pagination/graphql.dart index 290922ff65f..fe3343ae541 100644 --- a/lib/store/pagination/graphql.dart +++ b/lib/store/pagination/graphql.dart @@ -81,7 +81,7 @@ class GraphQlPageProvider implements PageProvider { @override Future put( - T item, { + Iterable items, { bool ignoreBounds = false, int Function(T, T)? compare, }) async { diff --git a/lib/store/pagination/hive.dart b/lib/store/pagination/hive.dart index 15f634d8d04..f27eb8a4dab 100644 --- a/lib/store/pagination/hive.dart +++ b/lib/store/pagination/hive.dart @@ -192,7 +192,7 @@ class HivePageProvider } @override - Future put(T item, {int Function(T, T)? compare}) async { + Future put(Iterable items, {int Function(T, T)? compare}) async { // TODO: https://github.com/team113/messenger/issues/27 // Don't write to [Hive] from popup, as [Hive] doesn't support isolate // synchronization, thus writes from multiple applications may lead to @@ -201,27 +201,29 @@ class HivePageProvider return; } - if (compare == null) { - return _provider.put(item); - } - - final Iterable ordered = orderBy(_provider.keys).toList(); - - if (ordered.isNotEmpty) { - final T? firstItem = await _provider.get(ordered.first); - final T? lastItem = await _provider.get(ordered.last); + for (var item in items) { + if (compare == null) { + return _provider.put(item); + } - if (firstItem != null && lastItem != null) { - if (compare(item, lastItem) == 1) { - if (isLast?.call(item) == true) { - await _provider.put(item); - } - } else if (compare(item, firstItem) == -1) { - if (isFirst?.call(item) == true) { + final Iterable ordered = orderBy(_provider.keys).toList(); + + if (ordered.isNotEmpty) { + final T? firstItem = await _provider.get(ordered.first); + final T? lastItem = await _provider.get(ordered.last); + + if (firstItem != null && lastItem != null) { + if (compare(item, lastItem) == 1) { + if (isLast?.call(item) == true) { + await _provider.put(item); + } + } else if (compare(item, firstItem) == -1) { + if (isFirst?.call(item) == true) { + await _provider.put(item); + } + } else { await _provider.put(item); } - } else { - await _provider.put(item); } } } diff --git a/lib/store/pagination/hive_graphql.dart b/lib/store/pagination/hive_graphql.dart index 01a9c918292..3bd8be6fd13 100644 --- a/lib/store/pagination/hive_graphql.dart +++ b/lib/store/pagination/hive_graphql.dart @@ -57,9 +57,7 @@ class HiveGraphQlPageProvider final Page remote = await graphQlProvider.around(key, cursor, count); - for (T e in remote.edges) { - hiveProvider.put(e); - } + await hiveProvider.put(remote.edges); return remote; } @@ -74,9 +72,7 @@ class HiveGraphQlPageProvider final Page remote = await graphQlProvider.after(key, cursor, count); - for (T e in remote.edges) { - await hiveProvider.put(e); - } + await hiveProvider.put(remote.edges); return remote; } @@ -91,16 +87,14 @@ class HiveGraphQlPageProvider final Page remote = await graphQlProvider.before(key, cursor, count); - for (T e in remote.edges) { - await hiveProvider.put(e); - } + await hiveProvider.put(remote.edges); return remote; } @override - Future put(T item, {int Function(T, T)? compare}) => - hiveProvider.put(item, compare: compare); + Future put(Iterable items, {int Function(T, T)? compare}) => + hiveProvider.put(items, compare: compare); @override Future remove(K key) => hiveProvider.remove(key); diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 97cfe26f65f..f62b2e9d376 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -836,6 +836,22 @@ class ChatController extends GetxController { } }); + _bottomLoaderStartTimer = Timer( + const Duration(seconds: 2), + () { + if ((!status.value.isSuccess || status.value.isLoadingMore) && + elements.isNotEmpty) { + _bottomLoader = LoaderElement.bottom( + (chat?.messages.lastOrNull?.value.at + .add(const Duration(microseconds: 1)) ?? + PreciseDateTime.now()), + ); + + elements[_bottomLoader!.id] = _bottomLoader!; + } + }, + ); + // If [RxChat.status] is not successful yet, populate the // [_messageInitializedWorker] to determine the initial messages list // index and offset. @@ -860,31 +876,8 @@ class ChatController extends GetxController { } } }); - } else { - _determineFirstUnread(); - final result = _calculateListViewIndex(); - initIndex = result.index; - initOffset = result.offset; - - status.value = RxStatus.loadingMore(); } - _bottomLoaderStartTimer = Timer( - const Duration(seconds: 2), - () { - if ((!status.value.isSuccess || status.value.isLoadingMore) && - elements.isNotEmpty) { - _bottomLoader = LoaderElement.bottom( - (chat?.messages.lastOrNull?.value.at - .add(const Duration(microseconds: 1)) ?? - PreciseDateTime.now()), - ); - - elements[_bottomLoader!.id] = _bottomLoader!; - } - }, - ); - span.finish(); span = _ready.startChild('around'); @@ -898,12 +891,20 @@ class ChatController extends GetxController { _subscribeFor(chat: chat); + if (chat!.status.value.isSuccess) { + _determineFirstUnread(); + final result = _calculateListViewIndex(); + initIndex = result.index; + initOffset = result.offset; + status.value = RxStatus.loadingMore(); + } + await chat!.around(); // Required in order for [Hive.boxEvents] to add the messages. await Future.delayed(Duration.zero); - Rx? firstUnread = _firstUnread; + final Rx? firstUnread = _firstUnread; _determineFirstUnread(); // Scroll to the last read message if [_firstUnread] was updated. @@ -2292,7 +2293,7 @@ class ChatController extends GetxController { index = 0; offset = 0; } else if (_firstUnread != null) { - int i = elements.values.toList().indexWhere((e) { + final int i = elements.values.toList().indexWhere((e) { if (e is ChatForwardElement) { if (e.note.value?.value.id == _firstUnread!.value.id) { return true; @@ -2305,6 +2306,7 @@ class ChatController extends GetxController { return e.id.id == _firstUnread!.value.id; }); + if (i != -1) { index = i; offset = (MediaQuery.of(onContext?.call() ?? router.context!) diff --git a/lib/ui/page/home/page/chat/info/view.dart b/lib/ui/page/home/page/chat/info/view.dart index 2aab5dee790..2f0e350c113 100644 --- a/lib/ui/page/home/page/chat/info/view.dart +++ b/lib/ui/page/home/page/chat/info/view.dart @@ -561,16 +561,7 @@ class ChatInfoView extends StatelessWidget { final Widget title = Row( children: [ const StyledBackButton(), - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: AvatarWidget.fromRxChat( - c.chat, - radius: AvatarRadius.medium, - ), - ), + AvatarWidget.fromRxChat(c.chat, radius: AvatarRadius.medium), const SizedBox(width: 10), Flexible( child: DefaultTextStyle.merge( diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index ebb74325375..57377d69fd2 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -159,19 +159,12 @@ class ChatView extends StatelessWidget { appBar: CustomAppBar( title: Row( children: [ - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: InkWell( - customBorder: const CircleBorder(), - onTap: onDetailsTap, - child: Center( - child: AvatarWidget.fromRxChat( - c.chat, - radius: AvatarRadius.medium, - ), + WidgetButton( + onPressed: onDetailsTap, + child: Center( + child: AvatarWidget.fromRxChat( + c.chat, + radius: AvatarRadius.medium, ), ), ), diff --git a/lib/ui/page/home/page/chat/widget/chat_forward.dart b/lib/ui/page/home/page/chat/widget/chat_forward.dart index 9455c1747eb..8d4ac6d838e 100644 --- a/lib/ui/page/home/page/chat/widget/chat_forward.dart +++ b/lib/ui/page/home/page/chat/widget/chat_forward.dart @@ -846,7 +846,7 @@ class _ChatForwardWidgetState extends State { snapshot.data ?? (member is RxUser? ? member : null); return Tooltip( - message: data?.title ?? user?.title, + message: data?.title ?? user?.title ?? ('dot'.l10n * 3), verticalOffset: 15, padding: const EdgeInsets.fromLTRB(7, 3, 7, 3), decoration: BoxDecoration( diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 3b7eccda2ef..bc07b93f523 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -1601,7 +1601,7 @@ class _ChatItemWidgetState extends State { snapshot.data ?? (member is RxUser? ? member : null); return Tooltip( - message: data?.title ?? user?.title, + message: data?.title ?? user?.title ?? ('dot'.l10n * 3), verticalOffset: 15, padding: const EdgeInsets.fromLTRB(7, 3, 7, 3), decoration: BoxDecoration( diff --git a/lib/ui/page/home/page/chat/widget/video_thumbnail/video_thumbnail.dart b/lib/ui/page/home/page/chat/widget/video_thumbnail/video_thumbnail.dart index 020a0924bf4..56ea1d85c84 100644 --- a/lib/ui/page/home/page/chat/widget/video_thumbnail/video_thumbnail.dart +++ b/lib/ui/page/home/page/chat/widget/video_thumbnail/video_thumbnail.dart @@ -229,28 +229,32 @@ class _VideoThumbnailState extends State { bool shouldReload = false; - await Backoff.run( - () async { - try { - await (await PlatformUtils.dio).head(widget.url!); - - // Reinitialize the [_controller] if an unexpected error was - // thrown. - if (shouldReload) { - await _player.open(Media(widget.url!), play: false); + try { + await Backoff.run( + () async { + try { + await (await PlatformUtils.dio).head(widget.url!); + + // Reinitialize the [_controller] if an unexpected error was + // thrown. + if (shouldReload) { + await _player.open(Media(widget.url!), play: false); + } + } catch (e) { + if (e is DioException && e.response?.statusCode == 403) { + widget.onError?.call(); + _cancelToken?.cancel(); + } else { + shouldReload = true; + rethrow; + } } - } catch (e) { - if (e is DioException && e.response?.statusCode == 403) { - widget.onError?.call(); - _cancelToken?.cancel(); - } else { - shouldReload = true; - rethrow; - } - } - }, - _cancelToken, - ); + }, + _cancelToken, + ); + } on OperationCanceledException { + // No-op. + } } } } diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index e9bdbc1e339..48da9cbfde4 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -1179,19 +1179,13 @@ Widget _bar(MyProfileController c, BuildContext context) { children: [ const SizedBox(width: 4), const StyledBackButton(), - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: Center( - child: Obx(() { - return AvatarWidget.fromMyUser( - c.myUser.value, - radius: AvatarRadius.medium, - ); - }), - ), + Center( + child: Obx(() { + return AvatarWidget.fromMyUser( + c.myUser.value, + radius: AvatarRadius.medium, + ); + }), ), const SizedBox(width: 10), Flexible( diff --git a/lib/ui/page/home/page/user/view.dart b/lib/ui/page/home/page/user/view.dart index e052ab11a09..0a6801df656 100644 --- a/lib/ui/page/home/page/user/view.dart +++ b/lib/ui/page/home/page/user/view.dart @@ -517,14 +517,8 @@ class UserView extends StatelessWidget { final Widget title = Row( children: [ const StyledBackButton(), - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: Center( - child: AvatarWidget.fromRxUser(c.user, radius: AvatarRadius.medium), - ), + Center( + child: AvatarWidget.fromRxUser(c.user, radius: AvatarRadius.medium), ), const SizedBox(width: 10), Expanded( diff --git a/lib/ui/page/home/tab/menu/view.dart b/lib/ui/page/home/tab/menu/view.dart index d2fafb7cfef..da9d6732d04 100644 --- a/lib/ui/page/home/tab/menu/view.dart +++ b/lib/ui/page/home/tab/menu/view.dart @@ -83,20 +83,14 @@ class MenuTabView extends StatelessWidget { ], child: Row( children: [ - Material( - elevation: 6, - type: MaterialType.circle, - shadowColor: style.colors.onBackgroundOpacity27, - color: style.colors.onPrimary, - child: Center( - child: Obx(() { - return AvatarWidget.fromMyUser( - c.myUser.value, - key: c.profileKey, - radius: AvatarRadius.medium, - ); - }), - ), + Center( + child: Obx(() { + return AvatarWidget.fromMyUser( + c.myUser.value, + key: c.profileKey, + radius: AvatarRadius.medium, + ); + }), ), const SizedBox(width: 10), Flexible( diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 2128c1dea93..fc0b9861e9a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -182,7 +182,6 @@ SPEC REPOS: - PromisesObjC - ReachabilitySwift - Sentry - - SentryPrivate - sqlite3 EXTERNAL SOURCES: @@ -275,14 +274,13 @@ SPEC CHECKSUMS: ReachabilitySwift: 2128f3a8c9107e1ad33574c6e58e8285d460b149 screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - Sentry: ebc12276bd17613a114ab359074096b6b3725203 - sentry_flutter: dff1df05dc39c83d04f9330b36360fc374574c5e - SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe - share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + Sentry: 51b056d96914a741f63eca774d118678b1eb05a1 + sentry_flutter: e8397d13e297a5d4b6be8a752e33140b21c5cc97 + share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqlite3: 02d1f07eaaa01f80a1c16b4b31dfcbb3345ee01a sqlite3_flutter_libs: 8d204ef443cf0d5c1c8b058044eab53f3943a9c5 - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 diff --git a/test/e2e/steps/reads_message.dart b/test/e2e/steps/reads_message.dart index 1a2ff14a4d6..0e29482f364 100644 --- a/test/e2e/steps/reads_message.dart +++ b/test/e2e/steps/reads_message.dart @@ -68,7 +68,7 @@ final StepDefinitionGeneric readsAllMessages = final ChatId chatId = context.world.groups[name]!; final ChatItemId lastItemId = - (await provider.getChat(chatId)).chat!.lastItem!.toHive().value.id; + (await provider.getChat(chatId)).chat!.lastItem!.toDto().value.id; await provider.readChat(chatId, lastItemId); diff --git a/test/e2e/steps/reply_message.dart b/test/e2e/steps/reply_message.dart index 34fde37f5f4..38f26e74514 100644 --- a/test/e2e/steps/reply_message.dart +++ b/test/e2e/steps/reply_message.dart @@ -20,7 +20,7 @@ import 'package:messenger/api/backend/extension/chat.dart'; import 'package:messenger/domain/model/chat.dart'; import 'package:messenger/domain/model/chat_item.dart'; import 'package:messenger/provider/gql/graphql.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; +import 'package:messenger/store/model/chat_item.dart'; import '../parameters/users.dart'; import '../world/custom_world.dart'; @@ -42,13 +42,13 @@ final StepDefinitionGeneric repliesToMessage = // TODO: Should use `searchItems` query or something, when backend // introduces such a query. - final HiveChatMessage message = + final DtoChatMessage message = (await provider.chatItems(chatId, first: 120)) .chat! .items .edges - .map((e) => e.toHive()) - .whereType() + .map((e) => e.toDto()) + .whereType() .firstWhere((e) => (e.value as ChatMessage).text?.val == text); await provider.postChatMessage( diff --git a/test/unit/call_test.dart b/test/unit/call_test.dart index b5491319445..f47b8507e9a 100644 --- a/test/unit/call_test.dart +++ b/test/unit/call_test.dart @@ -33,6 +33,7 @@ import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/call.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -105,6 +106,7 @@ void main() async { test('CallService registers and handles all ongoing call events', () async { final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); accountProvider.set(const UserId('me')); credentialsProvider.put( @@ -182,6 +184,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -283,6 +286,7 @@ void main() async { test('CallService registers and successfully answers the call', () async { final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); final graphQlProvider = _FakeGraphQlProvider(); Get.put(graphQlProvider); @@ -324,6 +328,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -370,6 +375,7 @@ void main() async { test('CallService registers and successfully starts the call', () async { final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); final graphQlProvider = _FakeGraphQlProvider(); @@ -410,6 +416,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_attachment_test.dart b/test/unit/chat_attachment_test.dart index a3c5f24b056..43d9d076f44 100644 --- a/test/unit/chat_attachment_test.dart +++ b/test/unit/chat_attachment_test.dart @@ -31,6 +31,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -76,6 +77,7 @@ void main() async { var credentialsProvider = Get.put(CredentialsHiveProvider()); await credentialsProvider.init(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -211,6 +213,7 @@ void main() async { Get.put(ChatRepository( graphQlProvider, Get.find(), + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -302,6 +305,7 @@ void main() async { ChatRepository( graphQlProvider, Get.find(), + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_avatar_test.dart b/test/unit/chat_avatar_test.dart index 516b1c9eb2c..a91122a6a02 100644 --- a/test/unit/chat_avatar_test.dart +++ b/test/unit/chat_avatar_test.dart @@ -28,6 +28,7 @@ import 'package:messenger/domain/repository/auth.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -79,6 +80,7 @@ void main() async { await chatHiveProvider.init(); await chatHiveProvider.clear(); final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -193,6 +195,7 @@ void main() async { final ChatRepository chatRepository = ChatRepository( graphQlProvider, chatHiveProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -291,6 +294,7 @@ void main() async { final ChatRepository chatRepository = ChatRepository( graphQlProvider, chatHiveProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_delete_message_test.dart b/test/unit/chat_delete_message_test.dart index cefd4db49a0..a158281c6e1 100644 --- a/test/unit/chat_delete_message_test.dart +++ b/test/unit/chat_delete_message_test.dart @@ -30,6 +30,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -93,6 +94,7 @@ void main() async { ), ); final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); var chatProvider = ChatHiveProvider(); await chatProvider.init(); final callCredentialsProvider = CallCredentialsHiveProvider(); @@ -196,12 +198,14 @@ void main() async { last: null, before: null, )).thenAnswer( - (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats))); + (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats)), + ); when(graphQlProvider.getChat( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), )).thenAnswer( - (_) => Future.value(GetChat$Query.fromJson({'chat': chatData}))); + (_) => Future.value(GetChat$Query.fromJson({'chat': chatData})), + ); when(graphQlProvider.favoriteChatsEvents(any)).thenAnswer( (_) => const Stream.empty(), @@ -235,7 +239,7 @@ void main() async { ); authService.init(); - UserRepository userRepository = + final UserRepository userRepository = Get.put(UserRepository(graphQlProvider, userProvider)); final CallRepository callRepository = Get.put( CallRepository( @@ -247,10 +251,11 @@ void main() async { me: const UserId('me'), ), ); - AbstractChatRepository chatRepository = Get.put( + final AbstractChatRepository chatRepository = Get.put( ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -261,7 +266,8 @@ void main() async { me: const UserId('me'), ), ); - ChatService chatService = Get.put(ChatService(chatRepository, authService)); + final ChatService chatService = + Get.put(ChatService(chatRepository, authService)); test('ChatService successfully deletes chat message', () async { when(graphQlProvider.deleteChatMessage( @@ -269,6 +275,7 @@ void main() async { )).thenAnswer((_) => Future.value()); Get.put(chatProvider); + Get.put(chatItemProvider); await chatService.deleteChatItem( ChatMessage( @@ -289,9 +296,14 @@ void main() async { () async { when(graphQlProvider.deleteChatMessage( const ChatItemId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )).thenThrow(const DeleteChatMessageException( - DeleteChatMessageErrorCode.unknownChatItem)); + )).thenThrow( + const DeleteChatMessageException( + DeleteChatMessageErrorCode.unknownChatItem, + ), + ); + Get.put(chatProvider); + Get.put(chatItemProvider); expect( () async => await chatService.deleteChatItem(ChatMessage( @@ -315,6 +327,7 @@ void main() async { )).thenAnswer((_) => Future.value()); Get.put(chatProvider); + Get.put(chatItemProvider); await chatService.hideChatItem(ChatMessage( const ChatItemId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -334,9 +347,11 @@ void main() async { when(graphQlProvider.hideChatItem( const ChatItemId('0d72d245-8425-467a-9ebd-082d4f47850b'), )).thenThrow( - const HideChatItemException(HideChatItemErrorCode.unknownChatItem)); + const HideChatItemException(HideChatItemErrorCode.unknownChatItem), + ); Get.put(chatProvider); + Get.put(chatItemProvider); expect( () async => await chatService.hideChatItem(ChatMessage( diff --git a/test/unit/chat_direct_link_test.dart b/test/unit/chat_direct_link_test.dart index 84f20f146f1..27b19be3b5e 100644 --- a/test/unit/chat_direct_link_test.dart +++ b/test/unit/chat_direct_link_test.dart @@ -29,6 +29,7 @@ import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/my_user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -141,6 +142,7 @@ void main() async { await myUserProvider.clear(); await Get.put(DraftHiveProvider()).init(); Get.put(UserDriftProvider(Get.find())); + Get.put(ChatItemDriftProvider(Get.find())); await Get.put(CallCredentialsHiveProvider()).init(); await Get.put(ChatCredentialsHiveProvider()).init(); await Get.put(MediaSettingsHiveProvider()).init(); @@ -212,6 +214,7 @@ void main() async { Get.find(), Get.find(), Get.find(), + Get.find(), callRepository, Get.find(), userRepository, diff --git a/test/unit/chat_edit_message_test.dart b/test/unit/chat_edit_message_test.dart index 2bc816f1be0..ec69b41b323 100644 --- a/test/unit/chat_edit_message_test.dart +++ b/test/unit/chat_edit_message_test.dart @@ -30,6 +30,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -75,6 +76,7 @@ void main() async { var credentialsProvider = Get.put(CredentialsHiveProvider()); await credentialsProvider.init(); var userProvider = Get.put(UserDriftProvider(database)); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); final callCredentialsProvider = CallCredentialsHiveProvider(); @@ -235,10 +237,12 @@ void main() async { me: const UserId('me'), ), ); - AbstractChatRepository chatRepository = Get.put( + final AbstractChatRepository chatRepository = + Get.put( ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -249,7 +253,8 @@ void main() async { me: const UserId('me'), ), ); - ChatService chatService = Get.put(ChatService(chatRepository, authService)); + final ChatService chatService = + Get.put(ChatService(chatRepository, authService)); when(graphQlProvider.editChatMessage( const ChatItemId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -278,7 +283,7 @@ void main() async { test( 'ChatService throws a EditChatMessageException when editing a ChatMessage', () async { - AbstractSettingsRepository settingsRepository = Get.put( + final AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( mediaSettingsProvider, applicationSettingsProvider, @@ -287,7 +292,7 @@ void main() async { ), ); - AuthService authService = Get.put( + final AuthService authService = Get.put( AuthService( Get.put(AuthRepository( graphQlProvider, @@ -300,7 +305,7 @@ void main() async { ); authService.init(); - UserRepository userRepository = + final UserRepository userRepository = UserRepository(graphQlProvider, userProvider); final CallRepository callRepository = Get.put( @@ -313,10 +318,12 @@ void main() async { me: const UserId('me'), ), ); - AbstractChatRepository chatRepository = Get.put( + final AbstractChatRepository chatRepository = + Get.put( ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_hide_test.dart b/test/unit/chat_hide_test.dart index 604d4dcedd9..5375d227485 100644 --- a/test/unit/chat_hide_test.dart +++ b/test/unit/chat_hide_test.dart @@ -26,6 +26,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -75,6 +76,7 @@ void main() async { var credentialsProvider = Get.put(CredentialsHiveProvider()); await credentialsProvider.init(); final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -251,6 +253,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -321,6 +324,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_leave_test.dart b/test/unit/chat_leave_test.dart index fefc6c12c03..c4d67513a71 100644 --- a/test/unit/chat_leave_test.dart +++ b/test/unit/chat_leave_test.dart @@ -26,6 +26,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -74,6 +75,7 @@ void main() async { var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -224,6 +226,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_members_test.dart b/test/unit/chat_members_test.dart index 588b425ed88..322bf25bc6e 100644 --- a/test/unit/chat_members_test.dart +++ b/test/unit/chat_members_test.dart @@ -26,6 +26,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -75,7 +76,6 @@ void main() async { await chatProvider.init(); var chatHiveProvider = Get.put(ChatHiveProvider()); await chatHiveProvider.init(); - final userProvider = UserDriftProvider(database); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -274,7 +274,7 @@ void main() async { ); Future init(GraphQlProvider graphQlProvider) async { - AbstractSettingsRepository settingsRepository = Get.put( + final AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( mediaSettingsProvider, applicationSettingsProvider, @@ -284,7 +284,7 @@ void main() async { ); Get.put(graphQlProvider); - AuthService authService = Get.put( + final AuthService authService = Get.put( AuthService( Get.put(AuthRepository( Get.find(), @@ -297,7 +297,10 @@ void main() async { ); authService.init(); - UserRepository userRepository = + final userProvider = UserDriftProvider(database); + Get.put(ChatItemDriftProvider(database)); + + final UserRepository userRepository = Get.put(UserRepository(graphQlProvider, userProvider)); final CallRepository callRepository = Get.put( CallRepository( @@ -309,10 +312,12 @@ void main() async { me: const UserId('me'), ), ); - AbstractChatRepository chatRepository = Get.put( + final AbstractChatRepository chatRepository = + Get.put( ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -323,6 +328,7 @@ void main() async { me: const UserId('me'), ), ); + return Get.put(ChatService(chatRepository, authService)); } @@ -341,7 +347,8 @@ void main() async { last: null, before: null, )).thenAnswer( - (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats))); + (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats)), + ); when(graphQlProvider.getChat( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -365,8 +372,6 @@ void main() async { any, )).thenAnswer((_) => const Stream.empty()); - ChatService chatService = await init(graphQlProvider); - test('ChatService successfully adds a participant to chat', () async { when(graphQlProvider.addChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -375,6 +380,8 @@ void main() async { AddChatMember$Mutation.fromJson(addChatMemberData).addChatMember as AddChatMember$Mutation$AddChatMember$ChatEventsVersioned)); + final ChatService chatService = await init(graphQlProvider); + await chatService.addChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), const UserId('0d72d245-8425-467a-9ebd-082d4f47850a'), @@ -393,6 +400,8 @@ void main() async { const UserId('0d72d245-8425-467a-9ebd-082d4f47850a'), )).thenThrow(const AddChatMemberException(AddChatMemberErrorCode.blocked)); + final ChatService chatService = await init(graphQlProvider); + expect( () async => await chatService.addChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -413,10 +422,14 @@ void main() async { when(graphQlProvider.removeChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), const UserId('0d72d245-8425-467a-9ebd-082d4f47850a'), - )).thenAnswer((_) => Future.value(RemoveChatMember$Mutation.fromJson( - removeChatMemberData, - ).removeChatMember - as RemoveChatMember$Mutation$RemoveChatMember$ChatEventsVersioned)); + )).thenAnswer( + (_) => Future.value(RemoveChatMember$Mutation.fromJson( + removeChatMemberData, + ).removeChatMember + as RemoveChatMember$Mutation$RemoveChatMember$ChatEventsVersioned), + ); + + final ChatService chatService = await init(graphQlProvider); await chatService.removeChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -437,6 +450,8 @@ void main() async { )).thenThrow( const RemoveChatMemberException(RemoveChatMemberErrorCode.unknownChat)); + final ChatService chatService = await init(graphQlProvider); + expect( () async => await chatService.removeChatMember( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), diff --git a/test/unit/chat_read_test.dart b/test/unit/chat_read_test.dart index 48d4116c092..3a31eb15ad3 100644 --- a/test/unit/chat_read_test.dart +++ b/test/unit/chat_read_test.dart @@ -27,6 +27,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -76,7 +77,6 @@ void main() async { await chatProvider.init(); var credentialsProvider = Get.put(CredentialsHiveProvider()); await credentialsProvider.init(); - final userProvider = UserDriftProvider(database); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -253,6 +253,8 @@ void main() async { ))); Get.put(chatProvider); + final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( @@ -278,6 +280,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -332,6 +335,8 @@ void main() async { )).thenThrow(const ReadChatException(ReadChatErrorCode.unknownChat)); Get.put(chatProvider); + final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( @@ -357,6 +362,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_rename_test.dart b/test/unit/chat_rename_test.dart index 4f88a457fab..3132e723fee 100644 --- a/test/unit/chat_rename_test.dart +++ b/test/unit/chat_rename_test.dart @@ -26,6 +26,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -76,6 +77,7 @@ void main() async { var draftProvider = DraftHiveProvider(); await draftProvider.init(); final userProvider = UserDriftProvider(database); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -272,6 +274,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -327,6 +330,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_reply_message_test.dart b/test/unit/chat_reply_message_test.dart index 0f811179d08..254cdfb58cf 100644 --- a/test/unit/chat_reply_message_test.dart +++ b/test/unit/chat_reply_message_test.dart @@ -28,6 +28,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -77,8 +78,6 @@ void main() async { await credentialsProvider.init(); var draftProvider = Get.put(DraftHiveProvider()); await draftProvider.init(); - final userProvider = UserDriftProvider(database); - await userProvider.clear(); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -271,6 +270,8 @@ void main() async { ); Get.put(chatProvider); + final userProvider = UserDriftProvider(database); + Get.put(ChatItemDriftProvider(database)); AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( @@ -296,6 +297,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -365,6 +367,8 @@ void main() async { const PostChatMessageException(PostChatMessageErrorCode.blocked)); Get.put(chatProvider); + final userProvider = UserDriftProvider(database); + Get.put(ChatItemDriftProvider(database)); AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( @@ -390,6 +394,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/chat_split_message_test.dart b/test/unit/chat_split_message_test.dart index f070b42e173..6d7e2ab8bb1 100644 --- a/test/unit/chat_split_message_test.dart +++ b/test/unit/chat_split_message_test.dart @@ -33,6 +33,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -90,6 +91,7 @@ void main() async { var draftProvider = Get.put(DraftHiveProvider(), permanent: true); await draftProvider.init(); final userProvider = Get.put(UserDriftProvider(database), permanent: true); + Get.put(ChatItemDriftProvider(database), permanent: true); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -255,6 +257,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -334,6 +337,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -421,6 +425,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -502,6 +507,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -575,6 +581,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -662,6 +669,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/unit/pagination_combined_test.dart b/test/unit/pagination_combined_test.dart index c19d4439498..682480e7cde 100644 --- a/test/unit/pagination_combined_test.dart +++ b/test/unit/pagination_combined_test.dart @@ -147,7 +147,7 @@ class _ListPageProvider implements PageProvider { @override Future put( - int item, { + Iterable items, { bool ignoreBounds = false, int Function(int, int)? compare, }) async {} diff --git a/test/unit/pagination_test.dart b/test/unit/pagination_test.dart index 959fe376350..7de6851d96d 100644 --- a/test/unit/pagination_test.dart +++ b/test/unit/pagination_test.dart @@ -26,7 +26,6 @@ import 'package:messenger/config.dart'; import 'package:messenger/domain/model/chat.dart'; import 'package:messenger/domain/model/chat_item.dart'; import 'package:messenger/provider/gql/graphql.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/store/model/chat_item.dart'; import 'package:messenger/store/model/page_info.dart'; import 'package:messenger/store/pagination.dart'; @@ -204,11 +203,11 @@ void main() async { after: const ChatItemsCursor('90'), )).thenAnswer((_) => chatItems(first: 10, after: 90)); - final Pagination pagination = + final Pagination pagination = Pagination( perPage: 10, onKey: (i) => i.value.key, - provider: GraphQlPageProvider( + provider: GraphQlPageProvider( fetch: ({after, before, first, last}) async { final q = await graphQlProvider.chatItems( chatId, @@ -221,7 +220,7 @@ void main() async { final PageInfo info = q.chat!.items.pageInfo.toModel((c) => ChatItemsCursor(c)); return Page( - RxList(q.chat!.items.edges.map((e) => e.toHive()).toList()), + RxList(q.chat!.items.edges.map((e) => e.toDto()).toList()), PageInfo( hasNext: info.hasNext, hasPrevious: info.hasPrevious, @@ -351,7 +350,7 @@ class _ListPageProvider implements PageProvider { @override Future put( - int item, { + Iterable items, { bool ignoreBounds = false, int Function(int, int)? compare, }) async {} diff --git a/test/unit/toggle_chat_mute_test.dart b/test/unit/toggle_chat_mute_test.dart index 5da69df5cc2..6fc5756395c 100644 --- a/test/unit/toggle_chat_mute_test.dart +++ b/test/unit/toggle_chat_mute_test.dart @@ -26,6 +26,7 @@ import 'package:messenger/domain/repository/chat.dart'; import 'package:messenger/domain/repository/settings.dart'; import 'package:messenger/domain/service/auth.dart'; import 'package:messenger/domain/service/chat.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/exceptions.dart'; @@ -70,6 +71,7 @@ void main() async { var credentialsProvider = Get.put(CredentialsHiveProvider()); await credentialsProvider.init(); final userProvider = Get.put(UserDriftProvider(database)); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); var chatHiveProvider = Get.put(ChatHiveProvider()); await chatHiveProvider.init(); final callCredentialsProvider = Get.put(CallCredentialsHiveProvider()); @@ -230,6 +232,7 @@ void main() async { ChatRepository( graphQlProvider, chatHiveProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -296,6 +299,7 @@ void main() async { ChatRepository( graphQlProvider, chatHiveProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_attachment_test.dart b/test/widget/chat_attachment_test.dart index 89312b103e1..1921710b17a 100644 --- a/test/widget/chat_attachment_test.dart +++ b/test/widget/chat_attachment_test.dart @@ -42,6 +42,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -54,7 +55,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; import 'package:messenger/provider/hive/draft.dart'; @@ -347,6 +347,7 @@ void main() async { await draftProvider.init(); await draftProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -379,11 +380,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(userId: const UserId('me')); - await messagesProvider.clear(); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -405,7 +401,7 @@ void main() async { (WidgetTester tester) async { CacheWorker.instance = CacheWorker(null, null); - AuthService authService = Get.put( + final AuthService authService = Get.put( AuthService( Get.put(AuthRepository( Get.find(), @@ -421,9 +417,9 @@ void main() async { router = RouterState(authService); router.provider = MockPlatformRouteInformationProvider(); - UserRepository userRepository = + final UserRepository userRepository = Get.put(UserRepository(graphQlProvider, userProvider)); - BlocklistRepository blocklistRepository = Get.put( + final BlocklistRepository blocklistRepository = Get.put( BlocklistRepository( graphQlProvider, blockedUsersProvider, @@ -432,7 +428,7 @@ void main() async { sessionProvider, ), ); - AbstractSettingsRepository settingsRepository = Get.put( + final AbstractSettingsRepository settingsRepository = Get.put( SettingsRepository( settingsProvider, applicationSettingsProvider, @@ -448,10 +444,12 @@ void main() async { settingsRepository, me: const UserId('me'), ); - AbstractChatRepository chatRepository = Get.put( + final AbstractChatRepository chatRepository = + Get.put( ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, @@ -463,7 +461,7 @@ void main() async { ), ); - MyUserRepository myUserRepository = MyUserRepository( + final MyUserRepository myUserRepository = MyUserRepository( graphQlProvider, myUserProvider, blocklistRepository, @@ -485,7 +483,8 @@ void main() async { Get.put(ContactService(contactRepository)); Get.put(UserService(userRepository)); - ChatService chatService = Get.put(ChatService(chatRepository, authService)); + final ChatService chatService = + Get.put(ChatService(chatRepository, authService)); Get.put(CallService(authService, chatService, callRepository)); await tester.pumpWidget(createWidgetForTesting( diff --git a/test/widget/chat_direct_link_chat_test.dart b/test/widget/chat_direct_link_chat_test.dart index be47210cb27..98728e82d0e 100644 --- a/test/widget/chat_direct_link_chat_test.dart +++ b/test/widget/chat_direct_link_chat_test.dart @@ -37,6 +37,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -48,7 +49,6 @@ import 'package:messenger/provider/hive/blocklist_sorting.dart'; import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; @@ -150,6 +150,7 @@ void main() async { await contactProvider.init(); await contactProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -163,10 +164,6 @@ void main() async { await applicationSettingsProvider.init(); var backgroundProvider = BackgroundHiveProvider(); await backgroundProvider.init(); - var chatItemHiveProvider = ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b')); - await chatItemHiveProvider.init(); - await chatItemHiveProvider.clear(); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -353,6 +350,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_edit_message_test.dart b/test/widget/chat_edit_message_test.dart index 54260af6b09..23c7ca82da1 100644 --- a/test/widget/chat_edit_message_test.dart +++ b/test/widget/chat_edit_message_test.dart @@ -38,6 +38,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -50,7 +51,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; import 'package:messenger/provider/hive/draft.dart'; @@ -306,6 +306,7 @@ void main() async { await contactProvider.init(); await contactProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -345,12 +346,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(userId: const UserId('me')); - await messagesProvider.clear(); - Widget createWidgetForTesting({required Widget child}) { return MaterialApp( theme: Themes.light(), @@ -411,6 +406,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_hide_test.dart b/test/widget/chat_hide_test.dart index 98611034bef..88964d04570 100644 --- a/test/widget/chat_hide_test.dart +++ b/test/widget/chat_hide_test.dart @@ -34,6 +34,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -46,7 +47,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; import 'package:messenger/provider/hive/draft.dart'; @@ -139,6 +139,7 @@ void main() async { await contactProvider.clear(); await contactProvider.init(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -175,11 +176,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(); - Widget createWidgetForTesting({required Widget child}) { return MaterialApp( theme: Themes.light(), @@ -373,6 +369,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_rename_test.dart b/test/widget/chat_rename_test.dart index af46fd84aa8..512be851c28 100644 --- a/test/widget/chat_rename_test.dart +++ b/test/widget/chat_rename_test.dart @@ -36,6 +36,7 @@ import 'package:messenger/domain/service/call.dart'; import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -48,7 +49,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/draft.dart'; import 'package:messenger/provider/hive/favorite_chat.dart'; @@ -144,6 +144,7 @@ void main() async { await contactProvider.init(); await contactProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -176,11 +177,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(); - Widget createWidgetForTesting({required Widget child}) { return MaterialApp( theme: Themes.light(), @@ -347,6 +343,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_reply_message_test.dart b/test/widget/chat_reply_message_test.dart index c143ed80d16..2770e5bf7e9 100644 --- a/test/widget/chat_reply_message_test.dart +++ b/test/widget/chat_reply_message_test.dart @@ -37,6 +37,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -49,7 +50,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; import 'package:messenger/provider/hive/draft.dart'; @@ -350,6 +350,7 @@ void main() async { await contactProvider.init(); await contactProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -386,12 +387,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(userId: const UserId('me')); - await messagesProvider.clear(); - accountProvider.set(const UserId('me')); credentialsProvider.put( Credentials( @@ -471,6 +466,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, diff --git a/test/widget/chat_update_attachments_test.dart b/test/widget/chat_update_attachments_test.dart index 1d8cb251e91..f86b4f06439 100644 --- a/test/widget/chat_update_attachments_test.dart +++ b/test/widget/chat_update_attachments_test.dart @@ -42,6 +42,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -55,7 +56,6 @@ import 'package:messenger/provider/hive/call_credentials.dart'; import 'package:messenger/provider/hive/call_rect.dart'; import 'package:messenger/provider/hive/chat.dart'; import 'package:messenger/provider/hive/chat_credentials.dart'; -import 'package:messenger/provider/hive/chat_item.dart'; import 'package:messenger/provider/hive/contact.dart'; import 'package:messenger/provider/hive/contact_sorting.dart'; import 'package:messenger/provider/hive/draft.dart'; @@ -134,18 +134,20 @@ void main() async { when(graphQlProvider.disconnect()).thenAnswer((_) => () {}); when(graphQlProvider.keepOnline()).thenAnswer((_) => const Stream.empty()); - when(graphQlProvider.recentChatsTopEvents(3)).thenAnswer((_) => Stream.value( - QueryResult.internal( - source: QueryResultSource.network, - data: { - 'recentChatsTopEvents': { - '__typename': 'SubscriptionInitialized', - 'ok': true - } - }, - parserFn: (_) => null, - ), - )); + when(graphQlProvider.recentChatsTopEvents(3)).thenAnswer( + (_) => Stream.value( + QueryResult.internal( + source: QueryResultSource.network, + data: { + 'recentChatsTopEvents': { + '__typename': 'SubscriptionInitialized', + 'ok': true + } + }, + parserFn: (_) => null, + ), + ), + ); when(graphQlProvider.chatEvents( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), @@ -165,9 +167,9 @@ void main() async { )).thenAnswer((_) => Future.value(null)); when(graphQlProvider.chatItems( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - last: 50)) - .thenAnswer( + const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), + last: 50, + )).thenAnswer( (_) => Future.value(GetMessages$Query.fromJson({ 'chat': { 'items': { @@ -240,8 +242,10 @@ void main() async { after: null, last: null, )).thenAnswer( - (_) => Future.value(FavoriteContacts$Query.fromJson(favoriteChatContacts) - .favoriteChatContacts), + (_) => Future.value( + FavoriteContacts$Query.fromJson(favoriteChatContacts) + .favoriteChatContacts, + ), ); when(graphQlProvider.chatContacts( @@ -251,7 +255,8 @@ void main() async { after: null, last: null, )).thenAnswer( - (_) => Future.value(Contacts$Query.fromJson(chatContacts).chatContacts)); + (_) => Future.value(Contacts$Query.fromJson(chatContacts).chatContacts), + ); when(graphQlProvider.attachments(any)).thenAnswer( (_) => Future.value(GetAttachments$Query.fromJson({ @@ -302,27 +307,33 @@ void main() async { last: null, before: null, )).thenAnswer( - (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats))); + (_) => Future.value(FavoriteChats$Query.fromJson(favoriteChats)), + ); when(graphQlProvider.chatMembers( const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), first: anyNamed('first'), - )).thenAnswer((_) => Future.value(GetMembers$Query.fromJson({ - 'chat': { - 'members': { - 'edges': [], - 'pageInfo': { - 'endCursor': 'endCursor', - 'hasNextPage': false, - 'startCursor': 'startCursor', - 'hasPreviousPage': false, - } + )).thenAnswer( + (_) => Future.value(GetMembers$Query.fromJson({ + 'chat': { + 'members': { + 'edges': [], + 'pageInfo': { + 'endCursor': 'endCursor', + 'hasNextPage': false, + 'startCursor': 'startCursor', + 'hasPreviousPage': false, } } - }))); + } + })), + ); - when(graphQlProvider.incomingCalls()).thenAnswer((_) => Future.value( - IncomingCalls$Query$IncomingChatCalls.fromJson({'nodes': []}))); + when(graphQlProvider.incomingCalls()).thenAnswer( + (_) => Future.value( + IncomingCalls$Query$IncomingChatCalls.fromJson({'nodes': []}), + ), + ); when(graphQlProvider.incomingCallsTopEvents(3)) .thenAnswer((_) => const Stream.empty()); when(graphQlProvider.favoriteChatsEvents(any)) @@ -372,6 +383,7 @@ void main() async { await draftProvider.init(); await draftProvider.clear(); final userProvider = Get.put(UserDriftProvider(database)); + final chatItemProvider = Get.put(ChatItemDriftProvider(database)); var chatProvider = Get.put(ChatHiveProvider()); await chatProvider.init(); await chatProvider.clear(); @@ -406,11 +418,6 @@ void main() async { var blocklistSortingProvider = BlocklistSortingHiveProvider(); await blocklistSortingProvider.init(); - var messagesProvider = Get.put(ChatItemHiveProvider( - const ChatId('0d72d245-8425-467a-9ebd-082d4f47850b'), - )); - await messagesProvider.init(userId: const UserId('me')); - await messagesProvider.clear(); final callCredentialsProvider = CallCredentialsHiveProvider(); await callCredentialsProvider.init(); final chatCredentialsProvider = ChatCredentialsHiveProvider(); @@ -484,6 +491,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + chatItemProvider, recentChatProvider, favoriteChatProvider, callRepository, @@ -569,7 +577,7 @@ void main() async { await tester.pumpAndSettle(const Duration(seconds: 2)); - await database.close(); + await tester.runAsync(() async => await database.close()); await Get.deleteAll(force: true); }); } diff --git a/test/widget/user_profile_test.dart b/test/widget/user_profile_test.dart index 86f7798db8c..b71b8bab3a7 100644 --- a/test/widget/user_profile_test.dart +++ b/test/widget/user_profile_test.dart @@ -32,6 +32,7 @@ import 'package:messenger/domain/service/chat.dart'; import 'package:messenger/domain/service/contact.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:messenger/domain/service/user.dart'; +import 'package:messenger/provider/drift/chat_item.dart'; import 'package:messenger/provider/drift/drift.dart'; import 'package:messenger/provider/drift/user.dart'; import 'package:messenger/provider/gql/graphql.dart'; @@ -119,6 +120,7 @@ void main() async { await contactProvider.init(); await contactProvider.clear(); final userProvider = UserDriftProvider(database); + Get.put(ChatItemDriftProvider(database)); var chatProvider = ChatHiveProvider(); await chatProvider.init(); await chatProvider.clear(); @@ -448,6 +450,7 @@ void main() async { ChatRepository( graphQlProvider, chatProvider, + Get.find(), recentChatProvider, favoriteChatProvider, callRepository, From 6bb5175e1a912aa8671a44f41600369b3865c53f Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 20 May 2024 17:56:07 +0300 Subject: [PATCH 74/88] Add `BotInfo.more` and `CustomChatButton`s --- assets/icons/direct_link.svg | 1 + lib/domain/model/chat_item.dart | 15 +- lib/routes.dart | 30 ++-- lib/ui/page/home/controller.dart | 5 - lib/ui/page/home/page/chat/controller.dart | 30 ++++ .../home/page/chat/message_field/view.dart | 3 + .../chat/message_field/widget/buttons.dart | 17 ++ lib/ui/page/home/page/chat/view.dart | 158 +++++++++--------- lib/ui/page/home/page/my_profile/view.dart | 1 - lib/ui/page/home/router.dart | 7 + lib/ui/page/home/tab/chats/view.dart | 75 +++------ lib/ui/page/home/tab/link/controller.dart | 36 ++++ lib/ui/page/home/tab/link/view.dart | 46 +++++ lib/ui/page/home/view.dart | 50 ++---- lib/ui/page/home/widget/navigation_bar.dart | 14 +- lib/ui/page/link/controller.dart | 36 ++++ lib/ui/page/link/view.dart | 85 ++++++++++ .../page/widgets/section/navigation.dart | 3 +- lib/ui/widget/svg/svgs.dart | 6 + lib/util/backoff.dart | 4 +- 20 files changed, 408 insertions(+), 214 deletions(-) create mode 100644 assets/icons/direct_link.svg create mode 100644 lib/ui/page/home/tab/link/controller.dart create mode 100644 lib/ui/page/home/tab/link/view.dart create mode 100644 lib/ui/page/link/controller.dart create mode 100644 lib/ui/page/link/view.dart diff --git a/assets/icons/direct_link.svg b/assets/icons/direct_link.svg new file mode 100644 index 00000000000..6d488b96890 --- /dev/null +++ b/assets/icons/direct_link.svg @@ -0,0 +1 @@ + diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index b6b423a5b60..d22373d1a8e 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -407,7 +407,11 @@ class BotInfo extends ChatItem { text: text == null ? null : ChatMessageText(text), repliesTo: msg.repliesTo.firstOrNull, actions: (actions as List?)?.map((e) { - return BotAction(text: e['text'], command: e['command']); + return BotAction( + text: e['text'], + command: e['command'], + icon: e['icon'], + ); }).toList(), title: title ?? 'Bot', ); @@ -417,16 +421,12 @@ class BotInfo extends ChatItem { return null; } - @HiveField(5) ChatItemQuote? repliesTo; - @HiveField(6) ChatMessageText? text; - @HiveField(7) List? actions; - @HiveField(8) String title; } @@ -434,11 +434,12 @@ class BotAction { const BotAction({ required this.text, required this.command, + this.icon, }); - @HiveField(0) final String text; - @HiveField(1) final String command; + + final String? icon; } diff --git a/lib/routes.dart b/lib/routes.dart index 4c9c67b216e..258e7305346 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -101,6 +101,7 @@ class Routes { static const contacts = '/contacts'; static const erase = '/erase'; static const home = '/'; + static const link = '/link'; static const me = '/me'; static const menu = '/menu'; static const support = '/support'; @@ -119,7 +120,7 @@ class Routes { } /// List of [Routes.home] page tabs. -enum HomeTab { work, contacts, chats, menu } +enum HomeTab { link, chats, menu } /// List of [Routes.work] page sections. enum WorkTab { frontend, backend, freelance } @@ -347,11 +348,7 @@ class AppRouteInformationParser String route = routeInformation.uri.path; HomeTab? tab; - if (route.startsWith(Routes.work)) { - tab = HomeTab.work; - } else if (route.startsWith(Routes.contacts)) { - tab = HomeTab.contacts; - } else if (route.startsWith(Routes.chats)) { + if (route.startsWith(Routes.chats)) { tab = HomeTab.chats; } else if (route.startsWith(Routes.menu) || route == Routes.me) { tab = HomeTab.menu; @@ -380,12 +377,8 @@ class AppRouteInformationParser // If logged in and on [Routes.home] page, then modify the URL's route. if (configuration.authorized && configuration.route == Routes.home) { switch (configuration.tab!) { - case HomeTab.work: - route = Routes.work; - break; - - case HomeTab.contacts: - route = Routes.contacts; + case HomeTab.link: + route = Routes.link; break; case HomeTab.chats: @@ -848,6 +841,7 @@ class AppRouterDelegate extends RouterDelegate _state.route.startsWith(Routes.work) || _state.route.startsWith(Routes.erase) || _state.route.startsWith(Routes.support) || + _state.route == Routes.link || _state.route == Routes.me || _state.route == Routes.home) { _updateTabTitle(); @@ -894,15 +888,8 @@ class AppRouterDelegate extends RouterDelegate if (_state._auth.status.value.isSuccess) { switch (_state.tab) { - case HomeTab.work: - WebUtils.title('$prefix${'label_work_with_us'.l10n}'); - break; - - case HomeTab.contacts: - WebUtils.title('$prefix${'label_tab_contacts'.l10n}'); - break; - case HomeTab.chats: + case HomeTab.link: WebUtils.title('$prefix${'label_tab_chats'.l10n}'); break; @@ -990,6 +977,9 @@ extension RouteLinks on RouterState { /// Changes router location to the [Routes.nowhere] page. void nowhere() => go(Routes.nowhere); + + /// Changes router location to the [Routes.link] page. + void link() => go(Routes.link); } /// Extension adding helper methods to an [AppLifecycleState]. diff --git a/lib/ui/page/home/controller.dart b/lib/ui/page/home/controller.dart index 52403f4a8d4..10a2404f24b 100644 --- a/lib/ui/page/home/controller.dart +++ b/lib/ui/page/home/controller.dart @@ -116,11 +116,6 @@ class HomeController extends GetxController { /// Returns the [List] of the [HomeTab]s to display. List get tabs { final List tabs = HomeTab.values.toList(); - - if (settings.value?.workWithUsTabEnabled == false) { - tabs.remove(HomeTab.work); - } - return tabs; } diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index f62b2e9d376..012d2f889b1 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -99,6 +99,7 @@ import '/util/platform_utils.dart'; import '/util/web/web_utils.dart'; import 'forward/view.dart'; import 'message_field/controller.dart'; +import 'message_field/widget/buttons.dart'; import 'view.dart'; import 'widget/chat_gallery.dart'; @@ -2147,6 +2148,8 @@ class ChatController extends GetxController { final Rx botInfo = Rx(null); void _addBotInfo(RxUser e) { + Log.info('_addBotInfo($e)'); + if ((e.user.value.bio?.val.length ?? 0) > 'bot '.length) { final String? about = e.user.value.bio?.val.substring('bot '.length); @@ -2161,6 +2164,8 @@ class ChatController extends GetxController { decoded?[L10n.chosen.value!.toString()]?['text'] ?? decoded?['text']; final actions = decoded?[L10n.chosen.value!.toString()]?['actions'] ?? decoded?['actions']; + final more = + decoded?[L10n.chosen.value!.toString()]?['more'] ?? decoded?['more']; botInfo.value = BotInfoElement( text, @@ -2169,7 +2174,30 @@ class ChatController extends GetxController { return BotAction(text: e['text'], command: e['command']); }).toList() ?? [], + more: (more as List?)?.map((e) { + return BotAction( + text: e['text'], + command: e['command'], + icon: e['icon'], + ); + }).toList() ?? + [], ); + + send.panel.insertAll( + 0, + botInfo.value!.more.map( + (e) { + return CustomChatButton( + hint: e.text, + onPressed: () { + postCommand(e.command); + }, + ); + }, + ), + ); + // elements[botInfo.id] = botInfo; } } @@ -2546,12 +2574,14 @@ class BotInfoElement extends ListElement { this.string, { required PreciseDateTime at, this.actions = const [], + this.more = const [], }) : super(ListElementId(at, const ChatItemId('0'))); /// [String] of this [BotInfoElement]. final String? string; final List actions; + final List more; } /// [ListElement] representing a [ChatInfo]. diff --git a/lib/ui/page/home/page/chat/message_field/view.dart b/lib/ui/page/home/page/chat/message_field/view.dart index 825893afc7f..5c81279bc16 100644 --- a/lib/ui/page/home/page/chat/message_field/view.dart +++ b/lib/ui/page/home/page/chat/message_field/view.dart @@ -66,6 +66,7 @@ class MessageFieldView extends StatelessWidget { this.canForward = false, this.canAttach = true, this.constraints, + this.more = const [], }); /// Optionally provided external [MessageFieldController]. @@ -96,6 +97,8 @@ class MessageFieldView extends StatelessWidget { /// [BoxConstraints] replies, attachments and quotes are allowed to occupy. final BoxConstraints? constraints; + final List more; + /// Returns a [ThemeData] to decorate a [ReactiveTextField] with. static ThemeData theme(BuildContext context) { final style = Theme.of(context).style; diff --git a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart index dd75c584ef8..050e28160d6 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart @@ -224,3 +224,20 @@ class VideoCallButton extends ChatButton { @override SvgData get disabled => SvgIcons.chatVideoCallDisabled; } + +/// Custom [ChatButton]. +class CustomChatButton extends ChatButton { + const CustomChatButton({ + void Function()? onPressed, + required this.hint, + this.icon, + }) : super(onPressed); + + @override + final String hint; + + final String? icon; + + @override + SvgData get asset => SvgIcons.chatVideoCall; +} diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 57377d69fd2..db6e6b89bbf 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -1521,94 +1521,92 @@ class ChatView extends StatelessWidget { ); } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Obx(() { - final e = c.botInfo.value; - if (e == null) { - return const SizedBox(); - } + return Obx(() { + final e = c.botInfo.value; - const Color color = Color.fromARGB(255, 149, 209, 149); + const Color color = Color.fromARGB(255, 149, 209, 149); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (c.botInfo.value!.string != null) - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color, width: 0.5), - gradient: const LinearGradient( - stops: [0, 1], - colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (e != null) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (c.botInfo.value!.string != null) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color, width: 0.5), + gradient: const LinearGradient( + stops: [0, 1], + colors: [Color(0xFFE1F0F3), Color(0xFFBDF4FF)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Text( + c.botInfo.value!.string!, + style: style.fonts.small.regular.secondary, ), ), - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 4, - ), - child: Text( - c.botInfo.value!.string!, - style: style.fonts.small.regular.secondary, - ), - ), - if (c.botInfo.value!.string != null && e.actions.isNotEmpty) - const SizedBox(width: 8), - ...e.actions.map((e) { - return WidgetButton( - onPressed: () => c.postCommand(e.command), - child: Stack( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: const Color(0xFFD3E3E6), - width: 1, + if (c.botInfo.value!.string != null && + e.actions.isNotEmpty) + const SizedBox(width: 8), + ...e.actions.map((e) { + return WidgetButton( + onPressed: () => c.postCommand(e.command), + child: Stack( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: const Color(0xFFD3E3E6), + width: 1, + ), + color: const Color(0xFFddf1f4), + ), + child: Text( + e.text, + style: style.fonts.small.regular.primary, ), - color: const Color(0xFFddf1f4), - ), - child: Text( - e.text, - style: style.fonts.small.regular.primary, ), - ), - ], - ), - ); - }).between((a, b) => const SizedBox(width: 8)), - ], + ], + ), + ); + }).between((a, b) => const SizedBox(width: 8)), + ], + ), ), ), - ); - }), - MessageFieldView( - key: const Key('SendField'), - controller: c.send, - onChanged: - c.chat?.chat.value.isMonolog == true ? null : c.updateTyping, - onItemPressed: (item) => - c.animateTo(item.id, item: item, addToHistory: false), - canForward: true, - onAttachmentError: c.chat?.updateAttachments, - // symbols: c.hasBot, - ), - ], - ); + MessageFieldView( + key: const Key('SendField'), + controller: c.send, + onChanged: + c.chat?.chat.value.isMonolog == true ? null : c.updateTyping, + onItemPressed: (item) => + c.animateTo(item.id, item: item, addToHistory: false), + canForward: true, + onAttachmentError: c.chat?.updateAttachments, + ), + ], + ); + }); }); } diff --git a/lib/ui/page/home/page/my_profile/view.dart b/lib/ui/page/home/page/my_profile/view.dart index 48da9cbfde4..ab2f5ef942a 100644 --- a/lib/ui/page/home/page/my_profile/view.dart +++ b/lib/ui/page/home/page/my_profile/view.dart @@ -126,7 +126,6 @@ class MyProfileView extends StatelessWidget { child: Column( children: [ block( - title: 'label_avatar'.l10n, children: [ Obx(() { return BigAvatarWidget.myUser( diff --git a/lib/ui/page/home/router.dart b/lib/ui/page/home/router.dart index d826973d925..073162a25c7 100644 --- a/lib/ui/page/home/router.dart +++ b/lib/ui/page/home/router.dart @@ -17,6 +17,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:messenger/ui/page/link/view.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '/domain/model/chat_item.dart'; @@ -127,6 +128,12 @@ class HomeRouterDelegate extends RouterDelegate name: Routes.support, child: SupportView(), )); + } else if (route == Routes.link) { + pages.add(const CustomPage( + key: ValueKey('LinkPage'), + name: Routes.link, + child: LinkView(), + )); } } diff --git a/lib/ui/page/home/tab/chats/view.dart b/lib/ui/page/home/tab/chats/view.dart index 1f9b15727a4..a352bef9ca3 100644 --- a/lib/ui/page/home/tab/chats/view.dart +++ b/lib/ui/page/home/tab/chats/view.dart @@ -241,30 +241,31 @@ class ChatsTabView extends StatelessWidget { ); } - if (c.searching.value || c.groupCreating.value) { - return AnimatedButton( - key: c.searching.value - ? const Key('CloseSearchButton') - : const Key('SearchButton'), - onPressed: c.searching.value - ? () => c.closeSearch(c.groupCreating.isFalse) - : () => c.startSearch(), - decorator: (child) { - return Container( - padding: - const EdgeInsets.only(left: 20, right: 6), - width: 46, - height: double.infinity, - child: child, - ); - }, - child: Center( - child: c.groupCreating.value && c.searching.value - ? const SvgIcon(SvgIcons.back) - : const SvgIcon(SvgIcons.search), - ), - ); - } + // TODO: Uncomment, when searching is supported. + // if (c.searching.value || c.groupCreating.value) { + // return AnimatedButton( + // key: c.searching.value + // ? const Key('CloseSearchButton') + // : const Key('SearchButton'), + // onPressed: c.searching.value + // ? () => c.closeSearch(c.groupCreating.isFalse) + // : () => c.startSearch(), + // decorator: (child) { + // return Container( + // padding: + // const EdgeInsets.only(left: 20, right: 6), + // width: 46, + // height: double.infinity, + // child: child, + // ); + // }, + // child: Center( + // child: c.groupCreating.value && c.searching.value + // ? const SvgIcon(SvgIcons.back) + // : const SvgIcon(SvgIcons.search), + // ), + // ); + // } return const SizedBox(width: 21); }), @@ -335,32 +336,6 @@ class ChatsTabView extends StatelessWidget { return Row( children: [ - AnimatedButton( - key: const Key('ContactsButton'), - onPressed: () => router.tab = HomeTab.contacts, - decorator: (child) { - return Container( - padding: - const EdgeInsets.only(left: 20, right: 12), - height: double.infinity, - child: child, - ); - }, - child: const SvgIcon(SvgIcons.contactsSwitch), - ), - AnimatedButton( - key: const Key('SearchButton'), - onPressed: () => c.startSearch(), - decorator: (child) { - return Container( - padding: - const EdgeInsets.only(left: 20, right: 12), - height: double.infinity, - child: child, - ); - }, - child: const SvgIcon(SvgIcons.search), - ), ContextMenuRegion( key: const Key('ChatsMenu'), selector: c.moreKey, diff --git a/lib/ui/page/home/tab/link/controller.dart b/lib/ui/page/home/tab/link/controller.dart new file mode 100644 index 00000000000..8bf22677422 --- /dev/null +++ b/lib/ui/page/home/tab/link/controller.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; + +import 'package:get/get.dart'; + +import '/domain/model/my_user.dart'; +import '/domain/model/user.dart'; +import '/domain/repository/settings.dart'; +import '/domain/service/my_user.dart'; + +class LinkTabController extends GetxController { + LinkTabController(this._myUserService, this._settingsRepo); + + /// Service responsible for [MyUser] management. + final MyUserService _myUserService; + + /// Settings repository, used to update the [ApplicationSettings]. + final AbstractSettingsRepository _settingsRepo; + + /// Returns the currently authenticated [MyUser]. + Rx get myUser => _myUserService.myUser; + + /// Returns the current background's [Uint8List] value. + Rx get background => _settingsRepo.background; + + /// Creates a new [ChatDirectLink] with the specified [ChatDirectLinkSlug] and + /// deletes the current active [ChatDirectLink] of the authenticated [MyUser] + /// (if any). + Future createChatDirectLink(ChatDirectLinkSlug slug) async { + await _myUserService.createChatDirectLink(slug); + } + + /// Deletes the current [ChatDirectLink] of the authenticated [MyUser]. + Future deleteChatDirectLink() async { + await _myUserService.deleteChatDirectLink(); + } +} diff --git a/lib/ui/page/home/tab/link/view.dart b/lib/ui/page/home/tab/link/view.dart new file mode 100644 index 00000000000..0319ce7b976 --- /dev/null +++ b/lib/ui/page/home/tab/link/view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:messenger/ui/page/home/widget/direct_link.dart'; + +import '/l10n/l10n.dart'; +import '/ui/page/home/widget/app_bar.dart'; +import '/ui/page/home/widget/block.dart'; +import 'controller.dart'; + +class LinkTabView extends StatelessWidget { + const LinkTabView({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LinkTabController(Get.find(), Get.find()), + builder: (LinkTabController c) { + return Scaffold( + appBar: CustomAppBar(title: Text('btn_share'.l10n)), + body: ListView( + children: [ + Block( + title: 'label_your_direct_link'.l10n, + children: [ + Obx(() { + return DirectLinkField( + c.myUser.value?.chatDirectLink, + onSubmit: (s) async { + if (s == null) { + await c.deleteChatDirectLink(); + } else { + await c.createChatDirectLink(s); + } + }, + background: c.background.value, + ); + }), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/ui/page/home/view.dart b/lib/ui/page/home/view.dart index 0c28a1d7a67..eff834b83af 100644 --- a/lib/ui/page/home/view.dart +++ b/lib/ui/page/home/view.dart @@ -21,17 +21,15 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/page/link/view.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '/domain/model/user.dart'; -import '/l10n/l10n.dart'; import '/routes.dart'; import '/themes.dart'; import '/ui/page/call/widget/conditional_backdrop.dart'; import '/ui/page/call/widget/scaler.dart'; import '/ui/widget/animated_switcher.dart'; -import '/ui/widget/context_menu/menu.dart'; -import '/ui/widget/context_menu/tile.dart'; import '/ui/widget/progress_indicator.dart'; import '/ui/widget/svg/svg.dart'; import '/util/platform_utils.dart'; @@ -40,9 +38,7 @@ import 'controller.dart'; import 'overlay/controller.dart'; import 'router.dart'; import 'tab/chats/controller.dart'; -import 'tab/contacts/controller.dart'; import 'tab/menu/controller.dart'; -import 'tab/work/view.dart'; import 'widget/animated_slider.dart'; import 'widget/keep_alive.dart'; import 'widget/navigation_bar.dart'; @@ -209,8 +205,8 @@ class _HomeViewState extends State { }, // [KeepAlivePage] used to keep the tabs' states. children: const [ - KeepAlivePage(child: WorkTabView()), - KeepAlivePage(child: ContactsTabView()), + // KeepAlivePage(child: LinkTabView()), + SizedBox(), KeepAlivePage(child: ChatsTabView()), KeepAlivePage(child: MenuTabView()), ], @@ -218,8 +214,7 @@ class _HomeViewState extends State { extendBody: true, bottomNavigationBar: SafeArea( child: Obx(() { - final List tabs = - c.tabs.where((e) => e != HomeTab.contacts).toList(); + final List tabs = c.tabs; return AnimatedSlider( duration: 300.milliseconds, @@ -230,12 +225,8 @@ class _HomeViewState extends State { key: c.panelKey, items: tabs.map((e) { switch (e) { - case HomeTab.work: - return const CustomNavigationBarItem.work(); - - case HomeTab.contacts: - return const CustomNavigationBarItem - .contacts(); + case HomeTab.link: + return const CustomNavigationBarItem.link(); case HomeTab.chats: return Obx(() { @@ -257,32 +248,19 @@ class _HomeViewState extends State { onAvatar: c.updateAvatar, selector: c.panelKey, myUser: c.myUser.value, - actions: [ - ContextMenuBuilder( - (_) => Obx(() { - final hasWork = c.settings.value - ?.workWithUsTabEnabled == - true; - - return ContextMenuTile( - asset: SvgIcons.partner, - label: 'label_work_with_us'.l10n, - pinned: hasWork, - onPressed: (_) => - c.setWorkWithUsTabEnabled( - !hasWork, - ), - ); - }), - ), - const ContextMenuDivider(), - ], ); }); } }).toList(), currentIndex: tabs.indexOf(router.tab), - onTap: (i) => c.pages.jumpToPage(tabs[i].index), + onTap: (i) { + if (i == 0) { + return LinkView.show(context); + // return router.link(); + } + + c.pages.jumpToPage(tabs[i].index); + }, ), ); }), diff --git a/lib/ui/page/home/widget/navigation_bar.dart b/lib/ui/page/home/widget/navigation_bar.dart index 25daca5eecc..b11e0534ae2 100644 --- a/lib/ui/page/home/widget/navigation_bar.dart +++ b/lib/ui/page/home/widget/navigation_bar.dart @@ -167,19 +167,11 @@ class CustomNavigationBarItem extends StatelessWidget { }); /// Constructs a [CustomNavigationBarItem] for a [HomeTab.work]. - const CustomNavigationBarItem.work({Key? key}) + const CustomNavigationBarItem.link({Key? key}) : this._( key: key, - tab: HomeTab.work, - child: const SvgIcon(SvgIcons.partner, key: Key('WorkButton')), - ); - - /// Constructs a [CustomNavigationBarItem] for a [HomeTab.contacts]. - const CustomNavigationBarItem.contacts({Key? key}) - : this._( - key: key, - tab: HomeTab.contacts, - child: const SvgIcon(SvgIcons.contacts, key: Key('ContactsButton')), + tab: HomeTab.link, + child: const SvgIcon(SvgIcons.directLink, key: Key('LinkButton')), ); /// Constructs a [CustomNavigationBarItem] for a [HomeTab.chats]. diff --git a/lib/ui/page/link/controller.dart b/lib/ui/page/link/controller.dart new file mode 100644 index 00000000000..575aee5e924 --- /dev/null +++ b/lib/ui/page/link/controller.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; + +import 'package:get/get.dart'; + +import '/domain/model/my_user.dart'; +import '/domain/model/user.dart'; +import '/domain/repository/settings.dart'; +import '/domain/service/my_user.dart'; + +class LinkController extends GetxController { + LinkController(this._myUserService, this._settingsRepo); + + /// Service responsible for [MyUser] management. + final MyUserService _myUserService; + + /// Settings repository, used to update the [ApplicationSettings]. + final AbstractSettingsRepository _settingsRepo; + + /// Returns the currently authenticated [MyUser]. + Rx get myUser => _myUserService.myUser; + + /// Returns the current background's [Uint8List] value. + Rx get background => _settingsRepo.background; + + /// Creates a new [ChatDirectLink] with the specified [ChatDirectLinkSlug] and + /// deletes the current active [ChatDirectLink] of the authenticated [MyUser] + /// (if any). + Future createChatDirectLink(ChatDirectLinkSlug slug) async { + await _myUserService.createChatDirectLink(slug); + } + + /// Deletes the current [ChatDirectLink] of the authenticated [MyUser]. + Future deleteChatDirectLink() async { + await _myUserService.deleteChatDirectLink(); + } +} diff --git a/lib/ui/page/link/view.dart b/lib/ui/page/link/view.dart new file mode 100644 index 00000000000..27f6cbec574 --- /dev/null +++ b/lib/ui/page/link/view.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:messenger/ui/page/home/widget/direct_link.dart'; +import 'package:messenger/ui/widget/modal_popup.dart'; + +import '../../../l10n/l10n.dart'; +import '../home/widget/app_bar.dart'; +import '../home/widget/block.dart'; +import 'controller.dart'; + +class LinkView extends StatelessWidget { + const LinkView({super.key}); + + /// Displays a [LinkView] wrapped in a [ModalPopup]. + static Future show(BuildContext context) { + return ModalPopup.show(context: context, child: const LinkView()); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LinkController(Get.find(), Get.find()), + builder: (LinkController c) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalPopupHeader(text: 'label_your_direct_link'.l10n), + Flexible( + child: ListView( + padding: ModalPopup.padding(context), + shrinkWrap: true, + children: [ + const SizedBox(height: 16), + Obx(() { + return DirectLinkField( + c.myUser.value?.chatDirectLink, + onSubmit: (s) async { + if (s == null) { + await c.deleteChatDirectLink(); + } else { + await c.createChatDirectLink(s); + } + }, + background: c.background.value, + ); + }), + const SizedBox(height: 16), + ], + ), + ), + ], + ); + + return Scaffold( + appBar: CustomAppBar(title: Text('btn_share'.l10n)), + body: Center( + child: ListView( + shrinkWrap: true, + children: [ + Block( + title: 'label_your_direct_link'.l10n, + children: [ + Obx(() { + return DirectLinkField( + c.myUser.value?.chatDirectLink, + onSubmit: (s) async { + if (s == null) { + await c.deleteChatDirectLink(); + } else { + await c.createChatDirectLink(s); + } + }, + background: c.background.value, + ); + }), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/page/style/page/widgets/section/navigation.dart b/lib/ui/page/style/page/widgets/section/navigation.dart index 9c0be2206cf..b8ff2f53d79 100644 --- a/lib/ui/page/style/page/widgets/section/navigation.dart +++ b/lib/ui/page/style/page/widgets/section/navigation.dart @@ -82,8 +82,7 @@ class NavigationSection { currentIndex: p.value, onTap: (t) => p.value = t, items: [ - const CustomNavigationBarItem.work(), - const CustomNavigationBarItem.contacts(), + const CustomNavigationBarItem.link(), CustomNavigationBarItem.chats(), CustomNavigationBarItem.menu(), ], diff --git a/lib/ui/widget/svg/svgs.dart b/lib/ui/widget/svg/svgs.dart index 3ee0f7d9626..baf25b8e038 100644 --- a/lib/ui/widget/svg/svgs.dart +++ b/lib/ui/widget/svg/svgs.dart @@ -1916,4 +1916,10 @@ class SvgIcons { width: 21.48, height: 21, ); + + static const SvgData directLink = SvgData( + 'assets/icons/direct_link.svg', + width: 32.04, + height: 33.5, + ); } diff --git a/lib/util/backoff.dart b/lib/util/backoff.dart index f7f339c7f71..a4c2bab4efb 100644 --- a/lib/util/backoff.dart +++ b/lib/util/backoff.dart @@ -48,14 +48,14 @@ class Backoff { try { return await callback(); - } catch (e) { + } catch (e, stackTrace) { if (backoff.inMilliseconds == 0) { backoff = _minBackoff; } else if (backoff < _maxBackoff) { backoff *= 2; } - Log.error(e.toString(), 'Backoff'); + Log.error('${e.toString()}, StackTrace: $stackTrace', 'Backoff'); } } }), From 066bc2a7cfb999312769a4abbc7bf3a9b9f217a5 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 21 May 2024 12:20:38 +0300 Subject: [PATCH 75/88] Merge branch 'migrate-chat-items-to-drift' into stable-design --- helm/messenger/CHANGELOG.md | 12 ++++++++++++ helm/messenger/conf/nginx.conf | 9 ++++++++- lib/provider/drift/connection/js.dart | 4 ++-- web/{drift_worker.dart.js => drift_worker.js} | 0 4 files changed, 22 insertions(+), 3 deletions(-) rename web/{drift_worker.dart.js => drift_worker.js} (100%) diff --git a/helm/messenger/CHANGELOG.md b/helm/messenger/CHANGELOG.md index 74c4d29459f..4a393337a5b 100644 --- a/helm/messenger/CHANGELOG.md +++ b/helm/messenger/CHANGELOG.md @@ -6,6 +6,18 @@ All user visible changes to this project will be documented in this file. This p +## [0.1.4] · 2024-??-?? (unreleased) +[0.1.4]: https://github.com/team113/messenger/tree/helm%2Fmessenger%2F0.1.4/helm/messenger + +### Changed + +- Set `Cross-Origin-Embedder-Policy` header to `credentialless` in Safari. ([#1004]) + +[#1004]: https://github.com/team113/messenger/pull/1004 + + + + ## [0.1.3] · 2024-05-17 [0.1.3]: https://github.com/team113/messenger/tree/helm%2Fmessenger%2F0.1.3/helm/messenger diff --git a/helm/messenger/conf/nginx.conf b/helm/messenger/conf/nginx.conf index 3cff41b9216..a9ce5ae1c02 100644 --- a/helm/messenger/conf/nginx.conf +++ b/helm/messenger/conf/nginx.conf @@ -36,6 +36,13 @@ map $http_upgrade $connection_upgrade { default upgrade; "" close; } +# TODO: Remove, when `drift` works on Safari with COEP set to `require-corp`: +# https://github.com/simolus3/drift/issues/2812#issuecomment-2122086577 +# Declares variable to set `Cross-Origin-Embedder-Policy` header with. +map $http_user_agent $coep { + default require-corp; + ~(^(?!.*(?:Chrome|Edge)).*Safari) credentialless; +} # Permanent redirection from `www.` to non-`www.`. server { @@ -67,7 +74,7 @@ server { # - WASM rendering. # - OPFS for `drift`. # See: https://resourcepolicy.fyi - add_header 'Cross-Origin-Embedder-Policy' 'require-corp' always; + add_header 'Cross-Origin-Embedder-Policy' $coep always; add_header 'Cross-Origin-Opener-Policy' 'same-origin' always; try_files $uri $uri/ /index.html; diff --git a/lib/provider/drift/connection/js.dart b/lib/provider/drift/connection/js.dart index 4baf7cd8bf6..65b3ea32d03 100644 --- a/lib/provider/drift/connection/js.dart +++ b/lib/provider/drift/connection/js.dart @@ -23,9 +23,9 @@ import 'package:log_me/log_me.dart'; QueryExecutor connect() { return DatabaseConnection.delayed(Future(() async { final result = await WasmDatabase.open( - databaseName: 'my_app_db', + databaseName: 'drift', sqlite3Uri: Uri.parse('sqlite3.wasm'), - driftWorkerUri: Uri.parse('drift_worker.dart.js'), + driftWorkerUri: Uri.parse('drift_worker.js'), ); Log.info('Using ${result.chosenImplementation} for `drift` backend.'); diff --git a/web/drift_worker.dart.js b/web/drift_worker.js similarity index 100% rename from web/drift_worker.dart.js rename to web/drift_worker.js From 2ac57434dd6fdaac940fc3145d6778bdc7a33d31 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 21 May 2024 12:20:43 +0300 Subject: [PATCH 76/88] Add `create` to `AppDatabase` --- lib/provider/drift/drift.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/provider/drift/drift.dart b/lib/provider/drift/drift.dart index 969bfa14072..f88d75c3187 100644 --- a/lib/provider/drift/drift.dart +++ b/lib/provider/drift/drift.dart @@ -73,11 +73,16 @@ class AppDatabase extends _$AppDatabase { Log.debug('MigrationStrategy.beforeOpen()', '$runtimeType'); await customStatement('PRAGMA foreign_keys = ON'); - await createMigrator().createAll(); }, ); } + /// Creates all tables, triggers, views, indexes and everything else defined + /// in the database, if they don't exist. + Future create() async { + await createMigrator().createAll(); + } + /// Resets everything, meaning dropping and re-creating every table. Future reset() async { Log.debug('reset()', '$runtimeType'); @@ -110,6 +115,7 @@ final class DriftProvider extends DisposableInterface { @override void onInit() async { Log.debug('onInit()', '$runtimeType'); + await db?.create(); super.onInit(); } From 385abe3fe95adc8884fca1e572c42e74caa9e577 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 21 May 2024 13:53:46 +0300 Subject: [PATCH 77/88] Add `PRAGMA journal_mode = WAL` --- lib/provider/drift/drift.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/provider/drift/drift.dart b/lib/provider/drift/drift.dart index f88d75c3187..f2095d69ab0 100644 --- a/lib/provider/drift/drift.dart +++ b/lib/provider/drift/drift.dart @@ -72,7 +72,8 @@ class AppDatabase extends _$AppDatabase { beforeOpen: (_) async { Log.debug('MigrationStrategy.beforeOpen()', '$runtimeType'); - await customStatement('PRAGMA foreign_keys = ON'); + await customStatement('PRAGMA foreign_keys = ON;'); + await customStatement('PRAGMA journal_mode = WAL;'); }, ); } From 42bf764ad2eadb5c3a9d1e1f4d3c3aa2080326d9 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 21 May 2024 15:11:34 +0300 Subject: [PATCH 78/88] Remove `opfsLocks` from `connect` on Web --- lib/provider/drift/connection/js.dart | 71 ++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/lib/provider/drift/connection/js.dart b/lib/provider/drift/connection/js.dart index 65b3ea32d03..e79393395cb 100644 --- a/lib/provider/drift/connection/js.dart +++ b/lib/provider/drift/connection/js.dart @@ -15,28 +15,87 @@ // along with this program. If not, see // . +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:drift/wasm.dart'; import 'package:log_me/log_me.dart'; +import '../../../util/web/web.dart'; + /// Obtains a database connection for running `drift` on the web. QueryExecutor connect() { return DatabaseConnection.delayed(Future(() async { - final result = await WasmDatabase.open( - databaseName: 'drift', + // TODO: Uncomment, when [WasmStorageImplementation.opfsLocks] doesn't throw + // file I/O errors in Chromium browsers. + // final result = await WasmDatabase.open( + // databaseName: 'drift', + // sqlite3Uri: Uri.parse('sqlite3.wasm'), + // driftWorkerUri: Uri.parse('drift_worker.js'), + // ); + // + // Log.info('Using ${result.chosenImplementation} for `drift` backend.'); + // + // if (result.missingFeatures.isNotEmpty) { + // Log.warning( + // 'Browser misses the following features in order for `drift` to be as performant as possible: ${result.missingFeatures}', + // ); + // } + // + // return result.resolvedExecutor; + + final WasmProbeResult probed = await WasmDatabase.probe( sqlite3Uri: Uri.parse('sqlite3.wasm'), driftWorkerUri: Uri.parse('drift_worker.js'), + databaseName: 'drift', ); - Log.info('Using ${result.chosenImplementation} for `drift` backend.'); + final List available = + probed.availableStorages.toList(); + + // TODO: Replace + if (!WebUtils.isSafari && !WebUtils.isFirefox) { + available.remove(WasmStorageImplementation.opfsLocks); + } + + checkExisting: + for (final (location, name) in probed.existingDatabases) { + if (name == 'drift') { + final implementationsForStorage = switch (location) { + WebStorageApi.indexedDb => const [ + WasmStorageImplementation.sharedIndexedDb, + WasmStorageImplementation.unsafeIndexedDb + ], + WebStorageApi.opfs => const [ + WasmStorageImplementation.opfsShared, + WasmStorageImplementation.opfsLocks, + ], + }; + + // If any of the implementations for this location is still available, + // we want to use it instead of another location. + if (implementationsForStorage.any(available.contains)) { + available.removeWhere((i) => !implementationsForStorage.contains(i)); + break checkExisting; + } + } + } + + // Enum values are ordered by preferability, so just pick the best option + // left. + available.sortBy((element) => element.index); + + final best = available.firstOrNull ?? WasmStorageImplementation.inMemory; + final DatabaseConnection connection = await probed.open(best, 'drift'); + + Log.info('Using $best for `drift` backend.'); - if (result.missingFeatures.isNotEmpty) { + if (probed.missingFeatures.isNotEmpty) { Log.warning( - 'Browser misses the following features in order for `drift` to be as performant as possible: ${result.missingFeatures}', + 'Browser misses the following features in order for `drift` to be as performant as possible: ${probed.missingFeatures}', ); } - return result.resolvedExecutor; + return connection; })); } From 54798e1e7938f36b52d101fa2bc787dc8e59e0db Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Tue, 21 May 2024 18:16:16 +0300 Subject: [PATCH 79/88] Use `rfw` for `BotMenu`s attachments --- Makefile | 2 +- lib/domain/model/chat_item.dart | 68 ++++++++++++++- lib/store/chat_rx.dart | 62 ++++++------- lib/ui/page/home/page/chat/controller.dart | 58 ++++++++----- .../chat/message_field/component/more.dart | 21 +++-- .../page/chat/message_field/controller.dart | 29 +++++++ .../page/home/page/chat/message_field/desc.js | 65 ++++++++++++++ .../home/page/chat/message_field/view.dart | 86 ++++++++++++++++++- .../chat/message_field/widget/buttons.dart | 14 +++ .../message_field/widget/chat_button.dart | 11 ++- .../message_field/widget/more_button.dart | 80 ++++++++++------- lib/ui/widget/svg/src/interface.dart | 13 +++ lib/ui/widget/svg/src/io.dart | 22 +++++ lib/ui/widget/svg/src/web.dart | 72 ++++++++++++++++ lib/ui/widget/svg/svg.dart | 30 +++++++ pubspec.lock | 8 ++ pubspec.yaml | 5 +- 17 files changed, 554 insertions(+), 92 deletions(-) create mode 100644 lib/ui/page/home/page/chat/message_field/desc.js diff --git a/Makefile b/Makefile index 1c4a1b656d3..ec62c7b9792 100644 --- a/Makefile +++ b/Makefile @@ -154,7 +154,7 @@ else dockerized=no endif else - flutter build $(or $(platform),apk) \ + flutter build $(or $(platform),apk) --no-tree-shake-icons \ $(if $(call eq,$(profile),yes),--profile,--release) \ $(if $(call eq,$(platform),web),--web-renderer html --source-maps,) \ $(if $(call eq,$(split-debug-info),yes),--split-debug-info=debug,) \ diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index d22373d1a8e..6b234f861da 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -437,9 +437,75 @@ class BotAction { this.icon, }); - final String text; + factory BotAction.fromJson(Map json) { + return BotAction( + text: json['text'], + command: json['command'] ?? '', + icon: json['icon'], + ); + } + final String text; final String command; + final String? icon; +} + +class BotMenu { + const BotMenu({ + required this.text, + this.command, + this.action, + this.icon, + this.more = const [], + this.trailing, + this.rfw, + }); + + factory BotMenu.fromJson(Map json) { + final more = json[L10n.chosen.value!.toString()]?['more'] ?? json['more']; + + return BotMenu( + text: json['text'], + command: json['command'], + icon: json['icon'], + action: json['action'] == null + ? null + : BotMenuAction.fromJson(json['action']), + more: (more as List?)?.map((e) => BotMenu.fromJson(e)).toList() ?? [], + trailing: + json['trailing'] == null ? null : BotMenu.fromJson(json['trailing']), + rfw: json['rfw'], + ); + } + final String text; + final String? command; + final BotMenuAction? action; final String? icon; + final List more; + final BotMenu? trailing; + final String? rfw; +} + +class BotMenuAction { + BotMenuAction({ + required this.type, + required this.command, + this.description, + this.rfw, + }); + + factory BotMenuAction.fromJson(Map json) { + return BotMenuAction( + type: json['type'], + command: json['command'], + description: json['description'], + rfw: json['rfw'], + ); + } + + final String? type; + final String? command; + final String? description; + final String? rfw; } diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index af18d5e4ceb..a2a6d2c5d94 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -751,35 +751,39 @@ class HiveRxChat extends RxChat { if (bots.isNotEmpty) { final msg = message.value as ChatMessage; - postChatMessage( - text: ChatMessageText.bot( - localized: { - const Locale('en', 'US'): ChatBotText( - title: 'Translation', - actions: [ - const BotAction(text: 'Send', command: '/resend'), - BotAction( - text: - 'Translate and send for \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', - command: '/proceed', - ), - ], - ), - const Locale('ru', 'RU'): ChatBotText( - title: 'Перевод', - actions: [ - const BotAction(text: 'Отправить', command: '/resend'), - BotAction( - text: - 'Перевести и отправить за \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', - command: '/proceed', - ), - ], - ), - }, - ), - repliesTo: [msg], - ); + if (bots.any( + ((e) => e.user.value.name?.val == 'Translation Service'), + )) { + postChatMessage( + text: ChatMessageText.bot( + localized: { + const Locale('en', 'US'): ChatBotText( + title: 'Translation', + actions: [ + const BotAction(text: 'Send', command: '/resend'), + BotAction( + text: + 'Translate and send for \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', + command: '/proceed', + ), + ], + ), + const Locale('ru', 'RU'): ChatBotText( + title: 'Перевод', + actions: [ + const BotAction(text: 'Отправить', command: '/resend'), + BotAction( + text: + 'Перевести и отправить за \$${(1.1 / 100 * (msg.text?.val.length ?? 0)).toStringAsFixed(2)}', + command: '/proceed', + ), + ], + ), + }, + ), + repliesTo: [msg], + ); + } } } } diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 012d2f889b1..1eb1801beb9 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -2156,7 +2156,8 @@ class ChatController extends GetxController { Map? decoded; try { decoded = jsonDecode(about!.substring('[@bot]'.length)); - } catch (_) { + } catch (e) { + print('error: $e'); // No-op. } @@ -2170,30 +2171,35 @@ class ChatController extends GetxController { botInfo.value = BotInfoElement( text, at: PreciseDateTime.now(), - actions: (actions as List?)?.map((e) { - return BotAction(text: e['text'], command: e['command']); - }).toList() ?? - [], - more: (more as List?)?.map((e) { - return BotAction( - text: e['text'], - command: e['command'], - icon: e['icon'], - ); - }).toList() ?? - [], + actions: + (actions as List?)?.map((e) => BotAction.fromJson(e)).toList() ?? + [], + more: (more as List?)?.map((e) => BotMenu.fromJson(e)).toList() ?? [], ); + Log.info('more: ${botInfo.value!.more}'); + send.panel.insertAll( 0, botInfo.value!.more.map( (e) { - return CustomChatButton( - hint: e.text, - onPressed: () { - postCommand(e.command); - }, - ); + return e.toChatButton(onPressed: (a) { + if (a.command != null) { + postCommand(a.command!); + } else if (a.action != null) { + switch (a.action?.type) { + case 'attach': + send.attachments; + send.actions.add( + RfwAttachment( + a.action?.rfw ?? e.rfw, + description: a.action?.description, + ), + ); + break; + } + } + }); }, ), ); @@ -2581,7 +2587,7 @@ class BotInfoElement extends ListElement { final String? string; final List actions; - final List more; + final List more; } /// [ListElement] representing a [ChatInfo]. @@ -2769,3 +2775,15 @@ class _ListViewIndexCalculationResult { /// Initial [FlutterListView] offset. final double offset; } + +extension on BotMenu { + CustomChatButton toChatButton({void Function(BotMenu)? onPressed}) { + return CustomChatButton( + hint: text, + icon: icon, + onPressed: onPressed == null ? null : () => onPressed(this), + buttons: more.map((e) => e.toChatButton(onPressed: onPressed)).toList(), + trailing: trailing?.toChatButton(onPressed: onPressed), + ); + } +} diff --git a/lib/ui/page/home/page/chat/message_field/component/more.dart b/lib/ui/page/home/page/chat/message_field/component/more.dart index b5b1891b27a..39bebea3b08 100644 --- a/lib/ui/page/home/page/chat/message_field/component/more.dart +++ b/lib/ui/page/home/page/chat/message_field/component/more.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/page/home/page/chat/message_field/widget/buttons.dart'; import '/themes.dart'; import '/ui/page/home/page/chat/message_field/controller.dart'; @@ -54,6 +55,8 @@ class _MessageFieldMoreState extends State /// [dismiss]ing this [MessageFieldMore] Worker? _worker; + ChatButton? _button; + @override void initState() { _controller = AnimationController( @@ -94,9 +97,11 @@ class _MessageFieldMoreState extends State ? 0 : (constraints.maxHeight - rect.bottom + rect.height); + final List buttons = _button?.buttons ?? widget.c.panel; + final List widgets = []; - for (int i = 0; i < widget.c.panel.length; ++i) { - final e = widget.c.panel.elementAt(i); + for (int i = 0; i < buttons.length; ++i) { + final e = buttons.elementAt(i); widgets.add( Obx(() { @@ -104,9 +109,15 @@ class _MessageFieldMoreState extends State return ChatMoreWidget( e, - pinned: contains, - onPressed: dismiss, - onPin: contains || widget.c.canPin.value + pinned: _button == null ? contains : null, + onPressed: () { + if (e.buttons.isNotEmpty) { + setState(() => _button = e); + } else { + dismiss(); + } + }, + onPin: _button == null && (contains || widget.c.canPin.value) ? () { if (widget.c.buttons.contains(e)) { widget.c.buttons.remove(e); diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index 5f3b8466457..ce6b2240f3f 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -24,6 +24,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:rfw/formats.dart'; +import 'package:rfw/rfw.dart'; +import 'package:uuid/uuid.dart'; import '/domain/model/application_settings.dart'; import '/domain/model/attachment.dart'; @@ -177,6 +180,9 @@ class MessageFieldController extends GetxController { /// [ChatItemQuoteInput]s to be forwarded. late final RxList quotes; + final RxList actions = RxList(); + final Rx hoveredAction = Rx(null); + /// [ChatItem] being edited. final Rx edited = Rx(null); @@ -508,3 +514,26 @@ class MessageFieldController extends GetxController { return persisted ?? []; } } + +class RfwAttachment { + RfwAttachment(String? rfw, {this.description}) { + if (rfw != null) { + runtime = Runtime(); + data = DynamicContent(); + + runtime?.update(coreName, createCoreWidgets()); + runtime?.update(mainName, parseLibraryFile(rfw)); + data?.update('description', description ?? ''); + } + } + + final String id = const Uuid().v4(); + + final String? description; + + Runtime? runtime; + DynamicContent? data; + + static const LibraryName coreName = LibraryName(['core', 'widgets']); + static const LibraryName mainName = LibraryName(['main']); +} diff --git a/lib/ui/page/home/page/chat/message_field/desc.js b/lib/ui/page/home/page/chat/message_field/desc.js new file mode 100644 index 00000000000..13cb2434053 --- /dev/null +++ b/lib/ui/page/home/page/chat/message_field/desc.js @@ -0,0 +1,65 @@ +import core.widgets; + +widget root = Container( + height: 104.0, + padding: [4.0, 4.0], + decoration: { + type: 'box', + borderRadius: [{ x: 8.0, y: 8.0 }], + gradient: { + type: 'linear', + begin: { x: 0.0, y: -1.0 }, + end: { x: 0.0, y: 1.0 }, + colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], + stops: [0.0, 0.32, 0.68, 1.0], + }, +}, + child: Container( + margin: [2.0, 2.0], + decoration: { + type: 'box', + borderRadius: [{ x: 8.0, y: 8.0 }], + gradient: { + type: 'linear', + begin: { x: -1.0, y: 0.0 }, + end: { x: 1.0, y: 0.0 }, + colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], + stops: [0.0, 0.32, 0.68, 1.0], + }, + }, + child: Center( + child: DefaultTextStyle( + style: { + color: 0xFFF3CD01, + fontSize: 32.0, + shadows: [ + { + offset: { x: 1.0, y: 1.0 }, + blurRadius: 3.0, + color: 0xE4AC9200, + }, + { + offset: { x: -1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0xE4FFFF00, + }, + { + offset: { x: 1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + { + offset: { x: -1.0, y: 1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + ], + }, + child: Text( + text: [data.description], + textDirection: 'ltr', + ), + ), + ), + ), +); diff --git a/lib/ui/page/home/page/chat/message_field/view.dart b/lib/ui/page/home/page/chat/message_field/view.dart index 5c81279bc16..b0dad605025 100644 --- a/lib/ui/page/home/page/chat/message_field/view.dart +++ b/lib/ui/page/home/page/chat/message_field/view.dart @@ -24,6 +24,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:path/path.dart' as p; +import 'package:rfw/rfw.dart'; import '/api/backend/schema.dart' show ChatCallFinishReason; import '/domain/model/attachment.dart'; @@ -356,7 +357,8 @@ class MessageFieldView extends StatelessWidget { curve: Curves.ease, child: Container( width: double.infinity, - padding: c.replied.isNotEmpty || + padding: c.actions.isNotEmpty || + c.replied.isNotEmpty || c.attachments.isNotEmpty || c.edited.value != null ? const EdgeInsets.fromLTRB(4, 6, 4, 6) @@ -420,7 +422,11 @@ class MessageFieldView extends StatelessWidget { ), ), ), - ] + ], + if (c.actions.isNotEmpty) ...[ + const SizedBox(height: 4), + ...c.actions.map((e) => _buildAction(context, e, c)) + ], ], ), ), @@ -537,6 +543,82 @@ class MessageFieldView extends StatelessWidget { }); } + Widget _buildRfw( + BuildContext context, + RfwAttachment action, + MessageFieldController c, + ) { + if (action.runtime == null) { + return Text('Attached ${action.description}'); + } + + return RemoteWidget( + runtime: action.runtime!, + data: action.data!, + widget: const FullyQualifiedWidgetName(RfwAttachment.mainName, 'root'), + onEvent: (String name, DynamicMap arguments) { + debugPrint('user triggered event "$name" with data: $arguments'); + }, + ); + } + + Widget _buildAction( + BuildContext context, + RfwAttachment action, + MessageFieldController c, + ) { + final style = Theme.of(context).style; + + return Dismissible( + key: Key(action.id), + direction: DismissDirection.horizontal, + onDismissed: (_) { + c.actions.remove(action); + }, + child: MouseRegion( + opaque: false, + onEnter: (d) => c.hoveredAction.value = action, + onExit: (d) => c.hoveredAction.value = null, + child: Container( + margin: const EdgeInsets.fromLTRB(2, 0, 2, 0), + decoration: BoxDecoration( + color: style.colors.secondaryHighlight, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: _buildRfw(context, action, c), + ), + ), + Obx(() { + return AnimatedOpacity( + duration: 200.milliseconds, + opacity: + c.hoveredAction.value == action || PlatformUtils.isMobile + ? 1 + : 0, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 3, 3, 0), + child: CloseButton( + key: const Key('CancelReplyButton'), + onPressed: () { + c.actions.remove(action); + }, + ), + ), + ); + }), + ], + ), + ), + ), + ); + } + /// Returns a visual representation of the provided [Attachment]. Widget _buildAttachment( BuildContext context, diff --git a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart index 050e28160d6..62f32d99a4c 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart @@ -47,6 +47,11 @@ abstract class ChatButton { /// Asset offset of this [ChatButton] in mini mode. Offset get offsetMini => Offset.zero; + String? get icon => null; + + List get buttons => const []; + ChatButton? get trailing => null; + @override int get hashCode => runtimeType.hashCode; @@ -231,13 +236,22 @@ class CustomChatButton extends ChatButton { void Function()? onPressed, required this.hint, this.icon, + this.buttons = const [], + this.trailing, }) : super(onPressed); @override final String hint; + @override final String? icon; @override SvgData get asset => SvgIcons.chatVideoCall; + + @override + final List buttons; + + @override + final ChatButton? trailing; } diff --git a/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart b/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart index df0d409d1ea..8e6f2716121 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart @@ -24,12 +24,17 @@ import 'buttons.dart'; /// [AnimatedButton] with an [icon]. class ChatButtonWidget extends StatelessWidget { /// Constructs a [ChatButtonWidget] from the provided [ChatButton]. - ChatButtonWidget(ChatButton button, {super.key}) - : onPressed = button.onPressed, + ChatButtonWidget( + ChatButton button, { + super.key, + void Function()? onPressed, + }) : onPressed = onPressed ?? button.onPressed, onLongPress = null, icon = Transform.translate( offset: button.offset, - child: SvgIcon(button.asset), + child: button.icon == null + ? SvgIcon(button.asset) + : SvgImage.network(button.icon!), ), disabledIcon = Transform.translate( offset: button.offset, diff --git a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart index 2bbe330d0a9..d87ca1b2540 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart @@ -24,6 +24,7 @@ import '/ui/widget/animated_switcher.dart'; import '/ui/widget/svg/svg.dart'; import '/ui/widget/widget_button.dart'; import 'buttons.dart'; +import 'chat_button.dart'; /// [AnimatedButton] with an [icon]. class ChatMoreWidget extends StatefulWidget { @@ -36,20 +37,18 @@ class ChatMoreWidget extends StatefulWidget { void Function()? onPressed, }) : label = button.hint, offset = button.offsetMini, - icon = SvgIcon(button.assetMini ?? button.asset) { - this.onPressed = button.onPressed == null - ? null - : () { - onPressed?.call(); - button.onPressed?.call(); - }; - } + icon = button.icon == null + ? SvgIcon(button.assetMini ?? button.asset) + : SvgImage.network(button.icon!), + trailing = button.trailing, + onDismiss = onPressed, + onPressed = button.onPressed; /// Callback, called when this [ChatMoreWidget] is pressed. late final void Function()? onPressed; /// Indicator whether this [ChatMoreWidget] is pinned. - final bool pinned; + final bool? pinned; /// Callback, called when this [ChatMoreWidget] is pinned. final void Function()? onPin; @@ -63,6 +62,10 @@ class ChatMoreWidget extends StatefulWidget { /// Icon to display. final Widget icon; + final ChatButton? trailing; + + final void Function()? onDismiss; + @override State createState() => _ChatMoreWidgetState(); } @@ -85,7 +88,13 @@ class _ChatMoreWidgetState extends State { onExit: (_) => setState(() => _hovered = false), opaque: false, child: WidgetButton( - onPressed: widget.onPressed, + onPressed: disabled + ? null + : () { + widget.onPressed?.call(); + widget.onDismiss?.call(); + }, + behavior: HitTestBehavior.deferToChild, child: Container( width: double.infinity, color: (_hovered && !disabled) @@ -119,29 +128,42 @@ class _ChatMoreWidgetState extends State { ), const Spacer(), const SizedBox(width: 16), - WidgetButton( - onPressed: widget.onPin ?? () {}, - child: SizedBox( - height: 40, - width: 40, - child: Center( - child: AnimatedButton( - child: SafeAnimatedSwitcher( - duration: 100.milliseconds, - child: widget.pinned - ? const SvgIcon(SvgIcons.unpin, key: Key('Unpin')) - : Opacity( - key: const Key('Pin'), - opacity: widget.onPin == null || disabled - ? 0.6 - : 1, - child: const SvgIcon(SvgIcons.pin), - ), + if (widget.trailing != null) + ChatButtonWidget( + widget.trailing!, + onPressed: () { + widget.trailing?.onPressed?.call(); + widget.onDismiss?.call(); + }, + ), + if (widget.pinned != null) ...[ + WidgetButton( + onPressed: widget.onPin ?? () {}, + child: SizedBox( + height: 40, + width: 40, + child: Center( + child: AnimatedButton( + child: SafeAnimatedSwitcher( + duration: 100.milliseconds, + child: widget.pinned! + ? const SvgIcon( + SvgIcons.unpin, + key: Key('Unpin'), + ) + : Opacity( + key: const Key('Pin'), + opacity: widget.onPin == null || disabled + ? 0.6 + : 1, + child: const SvgIcon(SvgIcons.pin), + ), + ), ), ), ), ), - ), + ], ], ), ), diff --git a/lib/ui/widget/svg/src/interface.dart b/lib/ui/widget/svg/src/interface.dart index 53217d41b56..17ef9876cb8 100644 --- a/lib/ui/widget/svg/src/interface.dart +++ b/lib/ui/widget/svg/src/interface.dart @@ -44,6 +44,19 @@ Widget svgFromAsset( }) => throw UnimplementedError(); +Widget svgFromUrl( + String url, { + Alignment alignment = Alignment.center, + bool excludeFromSemantics = false, + BoxFit fit = BoxFit.contain, + double? height, + Key? key, + WidgetBuilder? placeholderBuilder, + String? semanticsLabel, + double? width, +}) => + throw UnimplementedError(); + /// Instantiates a widget rendering an SVG picture from an [Uint8List]. /// /// Either the [width] and [height] arguments should be specified, or the widget diff --git a/lib/ui/widget/svg/src/io.dart b/lib/ui/widget/svg/src/io.dart index 0e196fd78e8..ea89bc81d42 100644 --- a/lib/ui/widget/svg/src/io.dart +++ b/lib/ui/widget/svg/src/io.dart @@ -55,6 +55,28 @@ Widget svgFromAsset( width: width, ); +Widget svgFromUrl( + String url, { + Alignment alignment = Alignment.center, + bool excludeFromSemantics = false, + BoxFit fit = BoxFit.contain, + double? height, + Key? key, + WidgetBuilder? placeholderBuilder, + String? semanticsLabel, + double? width, +}) => + SvgPicture.network( + url, + alignment: Alignment.center, + excludeFromSemantics: excludeFromSemantics, + fit: fit, + height: height, + key: key, + semanticsLabel: semanticsLabel, + width: width, + ); + /// Instantiates a widget rendering an SVG picture from an [Uint8List]. /// /// Either the [width] and [height] arguments should be specified, or the widget diff --git a/lib/ui/widget/svg/src/web.dart b/lib/ui/widget/svg/src/web.dart index 39fa293a2e4..3d1fddb4413 100644 --- a/lib/ui/widget/svg/src/web.dart +++ b/lib/ui/widget/svg/src/web.dart @@ -23,10 +23,12 @@ import 'dart:convert'; import 'dart:io'; import 'dart:js' as js; +import 'package:dio/dio.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../../util/platform_utils.dart'; import '/util/log.dart'; /// Instantiates a widget rendering an SVG picture from an [AssetBundle]. @@ -65,6 +67,30 @@ Widget svgFromAsset( ); } +Widget svgFromUrl( + String url, { + Key? key, + Alignment alignment = Alignment.center, + bool excludeFromSemantics = false, + BoxFit fit = BoxFit.contain, + double? height, + WidgetBuilder? placeholderBuilder, + String? semanticsLabel, + double? width, +}) { + return _BrowserSvg( + key: key, + loader: _NetworkSvgLoader(url), + alignment: alignment, + excludeFromSemantics: excludeFromSemantics, + fit: fit, + height: height, + placeholderBuilder: placeholderBuilder, + semanticsLabel: semanticsLabel, + width: width, + ); +} + /// Instantiates a widget rendering an SVG picture from an [Uint8List]. /// /// Either the [width] and [height] arguments should be specified, or the widget @@ -172,6 +198,52 @@ class _AssetSvgLoader implements _SvgLoader { int get hashCode => asset.hashCode; } +/// SVG picture loader from an asset. +class _NetworkSvgLoader implements _SvgLoader { + const _NetworkSvgLoader(this.url); + + /// Asset path of the SVG picture. + final String url; + + /// Naive [LinkedHashMap]-based cache of [Uint8List]s. + /// + /// FIFO policy is used, meaning if [_cache] exceeds its [_cacheSize], then + /// the first inserted element is removed. + static final LinkedHashMap _cache = LinkedHashMap(); + + /// Maximum allowed length of the [_cache]. + static const _cacheSize = 50; + + @override + FutureOr load() { + if (_cache[url] != null) { + return _cache[url]!; + } + + return Future(() async { + final response = await (await PlatformUtils.dio).get( + url, + options: Options(responseType: ResponseType.bytes), + ); + final Uint8List bytes = response.data; + + _cache[url] = bytes; + if (_cache.length > _cacheSize) { + _cache.remove(_cache.keys.first); + } + + return bytes; + }); + } + + @override + bool operator ==(Object other) => + other is _NetworkSvgLoader && other.url == url; + + @override + int get hashCode => url.hashCode; +} + /// SVG picture loader from raw bytes. class _BytesSvgLoader implements _SvgLoader { _BytesSvgLoader(this.bytes); diff --git a/lib/ui/widget/svg/svg.dart b/lib/ui/widget/svg/svg.dart index f9d663c7cb3..0fd7a679f73 100644 --- a/lib/ui/widget/svg/svg.dart +++ b/lib/ui/widget/svg/svg.dart @@ -55,6 +55,21 @@ class SvgImage extends StatelessWidget { this.semanticsLabel, this.excludeFromSemantics = false, }) : file = null, + url = null, + bytes = null; + + const SvgImage.network( + this.url, { + super.key, + this.alignment = Alignment.center, + this.fit = BoxFit.contain, + this.width, + this.height, + this.placeholderBuilder, + this.semanticsLabel, + this.excludeFromSemantics = false, + }) : asset = null, + file = null, bytes = null; /// Instantiates a widget rendering an SVG picture from an [Uint8List]. @@ -74,6 +89,7 @@ class SvgImage extends StatelessWidget { this.semanticsLabel, this.excludeFromSemantics = false, }) : file = null, + url = null, asset = null; /// Instantiates a widget rendering an SVG picture from a [File]. @@ -93,6 +109,7 @@ class SvgImage extends StatelessWidget { this.semanticsLabel, this.excludeFromSemantics = false, }) : bytes = null, + url = null, asset = null; /// Instantiates a widget rendering an SVG picture from a [SvgData]. @@ -109,11 +126,13 @@ class SvgImage extends StatelessWidget { }) : asset = data.asset, file = null, bytes = null, + url = null, width = width ?? data.width, height = height ?? data.height; /// Path to an asset containing an SVG image to display. final String? asset; + final String? url; /// [File] representing an SVG image to display. final File? file; @@ -169,6 +188,17 @@ class SvgImage extends StatelessWidget { semanticsLabel: semanticsLabel, excludeFromSemantics: excludeFromSemantics!, ); + } else if (url != null) { + return svgFromUrl( + url!, + alignment: alignment!, + fit: fit!, + width: width, + height: height, + placeholderBuilder: placeholderBuilder, + semanticsLabel: semanticsLabel, + excludeFromSemantics: excludeFromSemantics!, + ); } else { return svgFromFile( file!, diff --git a/pubspec.lock b/pubspec.lock index 82d68b9730c..39b25e8b186 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1599,6 +1599,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + rfw: + dependency: "direct main" + description: + name: rfw + sha256: "079eb640f510b2b0e606dd011e6ccf438d39def206133bca400ba19bc0074667" + url: "https://pub.dev" + source: hosted + version: "1.0.26" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b015466fb82..079d7d69373 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,13 +80,14 @@ dependencies: mime: ^1.0.4 mutex: ^3.0.1 open_file: ^3.3.2 - path: ^1.8.2 - path_provider: ^2.1.3 path_provider_android: ^2.1.0 + path_provider: ^2.1.3 + path: ^1.8.2 permission_handler: ^11.3.1 photo_view: ^0.15.0 platform_detect: ^2.0.7 pub_semver: ^2.1.4 + rfw: ^1.0.26 scrollable_positioned_list: git: url: https://github.com/SleepySquash/scrollable_positioned_list From 004c062bed561d9643fce610a58707c506c9de04 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Wed, 22 May 2024 17:44:40 +0300 Subject: [PATCH 80/88] Improve bot actions responses --- lib/domain/model/chat_item.dart | 18 ++- lib/domain/repository/chat.dart | 2 +- lib/domain/service/chat.dart | 26 ++-- lib/store/chat.dart | 4 +- lib/store/chat_rx.dart | 24 +-- lib/ui/page/home/page/chat/controller.dart | 31 +++- .../chat/message_field/component/more.dart | 1 + .../page/chat/message_field/controller.dart | 7 +- .../page/home/page/chat/widget/chat_item.dart | 42 ++++- lib/ui/page/home/tab/chats/view.dart | 49 +++--- lib/ui/page/link/view.dart | 147 ++++-------------- 11 files changed, 167 insertions(+), 184 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 6b234f861da..bdcd37dd075 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -226,20 +226,21 @@ class ChatBotText { const ChatBotText({ this.title, this.text, + this.rfw, this.actions = const [], }); final String? title; final String? text; + final String? rfw; final List actions; Map toMap() { return { if (title != null) 'title': title, if (text != null) 'text': text, - if (actions.isNotEmpty) - 'actions': - actions.map((e) => {'text': e.text, 'command': e.command}).toList(), + if (rfw != null) 'rfw': rfw, + if (actions.isNotEmpty) 'actions': actions.map((e) => e.toJson).toList(), }; } } @@ -379,6 +380,7 @@ class BotInfo extends ChatItem { required this.title, this.text, this.actions, + this.rfw, }); static BotInfo? parse(ChatMessage msg) { @@ -406,6 +408,7 @@ class BotInfo extends ChatItem { msg.at, text: text == null ? null : ChatMessageText(text), repliesTo: msg.repliesTo.firstOrNull, + rfw: decoded['rfw'], actions: (actions as List?)?.map((e) { return BotAction( text: e['text'], @@ -428,6 +431,7 @@ class BotInfo extends ChatItem { List? actions; String title; + final String? rfw; } class BotAction { @@ -445,6 +449,14 @@ class BotAction { ); } + Map toJson() { + return { + 'text': text, + 'command': command, + if (icon != null) 'icon': icon, + }; + } + final String text; final String command; final String? icon; diff --git a/lib/domain/repository/chat.dart b/lib/domain/repository/chat.dart index f868e93151b..d4c3eef874c 100644 --- a/lib/domain/repository/chat.dart +++ b/lib/domain/repository/chat.dart @@ -96,7 +96,7 @@ abstract class AbstractChatRepository { /// /// Specify [repliesTo] argument if the posted [ChatMessage] is going to be a /// reply to some other [ChatItem]. - Future sendChatMessage( + Future sendChatMessage( ChatId chatId, { ChatMessageText? text, List? attachments, diff --git a/lib/domain/service/chat.dart b/lib/domain/service/chat.dart index 1da685b7799..afc20ffd5cf 100644 --- a/lib/domain/service/chat.dart +++ b/lib/domain/service/chat.dart @@ -117,12 +117,12 @@ class ChatService extends DisposableService { /// /// Specify [repliesTo] argument if the posted [ChatMessage] is going to be a /// reply to some other [ChatItem]. - Future sendChatMessage( + Future sendChatMessage( ChatId chatId, { ChatMessageText? text, List? attachments, List repliesTo = const [], - }) { + }) async { Log.debug( 'sendChatMessage($chatId, $text, $attachments, $repliesTo)', '$runtimeType', @@ -139,15 +139,19 @@ class ChatService extends DisposableService { final List chunks = text.split(); int i = 0; - return Future.forEach( - chunks, - (text) => _chatRepository.sendChatMessage( - chatId, - text: text, - attachments: i++ != chunks.length - 1 ? null : attachments, - repliesTo: repliesTo, - ), - ); + final List results = []; + for (var e in chunks) { + results.add( + await _chatRepository.sendChatMessage( + chatId, + text: e, + attachments: i++ != chunks.length - 1 ? null : attachments, + repliesTo: repliesTo, + ), + ); + } + + return results.firstOrNull; } } diff --git a/lib/store/chat.dart b/lib/store/chat.dart index f47e2894fbb..a21666a718c 100644 --- a/lib/store/chat.dart +++ b/lib/store/chat.dart @@ -413,7 +413,7 @@ class ChatRepository extends DisposableInterface } @override - Future sendChatMessage( + Future sendChatMessage( ChatId chatId, { ChatMessageText? text, List? attachments, @@ -442,7 +442,7 @@ class ChatRepository extends DisposableInterface } } - await rxChat?.postChatMessage( + return await rxChat?.postChatMessage( existingId: local?.id, existingDateTime: local?.at, text: text, diff --git a/lib/store/chat_rx.dart b/lib/store/chat_rx.dart index 98c4b2879ae..c47828ad57e 100644 --- a/lib/store/chat_rx.dart +++ b/lib/store/chat_rx.dart @@ -1002,26 +1002,9 @@ class HiveRxChat extends RxChat { 'Translation service is enabled. Certificated translators in real-time. [Terms and services](https://google.com)', ), const Locale('ru', 'RU'): const ChatBotText( - title: 'Перевод', - text: - 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени. [Условия использования](https://google.com)', - ), - }, - ), - ); - } else if (user.user.value.name?.val == 'Donation Service') { - await postChatMessage( - text: ChatMessageText.bot( - localized: { - const Locale('en', 'US'): const ChatBotText( title: 'Translation', text: - 'Donation service is enabled. [Terms and services](https://google.com)', - ), - const Locale('ru', 'RU'): const ChatBotText( - title: 'Перевод', - text: - 'Подключен сервис донатов. [Условия использования](https://google.com)', + 'Подключен переводческий сервис. Сертифицированные переводчики в режиме реального времени. [Условия использования](https://google.com)', ), }, ), @@ -2024,7 +2007,10 @@ class HiveRxChat extends RxChat { if (msg.text?.val.startsWith('[@bot]') == true) { final userService = Get.findOrNull(); - if (userService != null) { + final bool isTranslate = + msg.text?.val.contains('Translation') == true; + + if (isTranslate && userService != null) { final search = userService.search(login: UserLogin('translateit')); search.around().then((_) { diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 1eb1801beb9..0132da020d1 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -460,6 +460,8 @@ class ChatController extends GetxController { if (send.field.text.trim().isNotEmpty || send.attachments.isNotEmpty || send.replied.isNotEmpty) { + final List actions = send.actions.toList(); + _chatService .sendChatMessage( chat?.chat.value.id ?? id, @@ -470,10 +472,18 @@ class ChatController extends GetxController { attachments: send.attachments.map((e) => e.value).toList(), ) .then( - (_) { + (item) { AudioUtils.once( AudioSource.asset('audio/message_sent.mp3'), ); + + if (item != null) { + for (var e in actions) { + if (e.command != null) { + postCommand(e.command!, repliesTo: item); + } + } + } }, ).onError( (_, __) { @@ -1143,6 +1153,24 @@ class ChatController extends GetxController { ), repliesTo: [repliesTo], ); + } else if (command.startsWith('/donate')) { + final double? sum = + double.tryParse(command.substring('/donate'.length)); + + if (sum != null) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + text: ChatBotText( + title: 'Donate', + text: '\$${sum.toStringAsFixed(2)}', + rfw: + "import core.widgets; widget root = Container( constraints: { 'minWidth': 300.0 }, height: 104.0, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Center( child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Text( text: [data.description], textDirection: 'ltr', ), ), ), ), );", + ), + ), + repliesTo: [repliesTo], + ); + } } } else { if (command.startsWith('/donate')) { @@ -2194,6 +2222,7 @@ class ChatController extends GetxController { RfwAttachment( a.action?.rfw ?? e.rfw, description: a.action?.description, + command: a.action?.command, ), ); break; diff --git a/lib/ui/page/home/page/chat/message_field/component/more.dart b/lib/ui/page/home/page/chat/message_field/component/more.dart index 39bebea3b08..c14e55f05dd 100644 --- a/lib/ui/page/home/page/chat/message_field/component/more.dart +++ b/lib/ui/page/home/page/chat/message_field/component/more.dart @@ -194,6 +194,7 @@ class _MessageFieldMoreState extends State end: 1.0, ).animate(_animation), child: Container( + constraints: const BoxConstraints(minWidth: 240), margin: EdgeInsets.only(bottom: bottom + 10), decoration: BoxDecoration( color: style.colors.onPrimary, diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index ce6b2240f3f..2e82e70daac 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -337,6 +337,7 @@ class MessageFieldController extends GetxController { forwarding.value = false; field.clear(unfocus: unfocus); field.unsubmit(); + actions.clear(); onChanged?.call(); } @@ -516,20 +517,20 @@ class MessageFieldController extends GetxController { } class RfwAttachment { - RfwAttachment(String? rfw, {this.description}) { + RfwAttachment(String? rfw, {this.description, this.command}) { if (rfw != null) { runtime = Runtime(); data = DynamicContent(); runtime?.update(coreName, createCoreWidgets()); runtime?.update(mainName, parseLibraryFile(rfw)); - data?.update('description', description ?? ''); + data?.update('description', description ?? '\$1.00'); } } final String id = const Uuid().v4(); - final String? description; + final String? command; Runtime? runtime; DynamicContent? data; diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 46277284b60..a59e69db5d0 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -24,7 +24,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show SelectedContent; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/page/home/page/chat/message_field/controller.dart'; import 'package:messenger/ui/widget/markdown.dart'; +import 'package:rfw/rfw.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../../../domain/service/chat.dart'; @@ -437,6 +439,8 @@ class _ChatItemWidgetState extends State { void didUpdateWidget(covariant ChatItemWidget oldWidget) { if (oldWidget.item != widget.item) { _populateWorker(); + } else if (oldWidget.infos != widget.infos) { + _populateInfos(); } super.didUpdateWidget(oldWidget); @@ -849,6 +853,21 @@ class _ChatItemWidgetState extends State { ); } + Widget _rfw(BuildContext context, RfwAttachment e) { + if (e.runtime == null) { + return Text('Attached ${e.description}'); + } + + return RemoteWidget( + runtime: e.runtime!, + data: e.data!, + widget: const FullyQualifiedWidgetName(RfwAttachment.mainName, 'root'), + onEvent: (String name, DynamicMap arguments) { + debugPrint('user triggered event "$name" with data: $arguments'); + }, + ); + } + Widget _botInfo(BuildContext context, BotInfo e) { final style = Theme.of(context).style; @@ -1259,7 +1278,10 @@ class _ChatItemWidgetState extends State { ) ], ), - ...widget.infos.map((e) => _botInfo(context, e)), + ...widget.infos + .where((e) => e.rfw == null) + .map((e) => _botInfo(context, e)), + ..._rfws.map((e) => _rfw(context, e)), ], ), ), @@ -1997,15 +2019,19 @@ class _ChatItemWidgetState extends State { }); } + RxList _rfws = RxList(); + /// Populates the [_worker] invoking the [_populateSpans] and /// [_populateGlobalKeys] on the [ChatItemWidget.item] changes. void _populateWorker() { _worker?.dispose(); _populateGlobalKeys(widget.item.value); _populateSpans(widget.item.value); + _populateInfos(); ChatMessageText? text; int attachments = 0; + int infos = widget.infos.length; if (widget.item.value is ChatMessage) { final msg = widget.item.value as ChatMessage; @@ -2038,10 +2064,24 @@ class _ChatItemWidgetState extends State { _populateSpans(item); text = item.text; } + + if (widget.infos.length != infos) { + _populateInfos(); + infos = widget.infos.length; + } } }); } + void _populateInfos() { + _rfws.clear(); + for (var e in widget.infos) { + if (e.rfw != null) { + _rfws.add(RfwAttachment(e.rfw!, description: e.text?.val)); + } + } + } + /// Populates the [_galleryKeys] from the provided [ChatMessage.attachments]. void _populateGlobalKeys(ChatItem msg) { if (msg is ChatMessage) { diff --git a/lib/ui/page/home/tab/chats/view.dart b/lib/ui/page/home/tab/chats/view.dart index 3dbd055ecf0..ef9b2ea57aa 100644 --- a/lib/ui/page/home/tab/chats/view.dart +++ b/lib/ui/page/home/tab/chats/view.dart @@ -241,31 +241,30 @@ class ChatsTabView extends StatelessWidget { ); } - // TODO: Uncomment, when searching is supported. - // if (c.searching.value || c.groupCreating.value) { - // return AnimatedButton( - // key: c.searching.value - // ? const Key('CloseSearchButton') - // : const Key('SearchButton'), - // onPressed: c.searching.value - // ? () => c.closeSearch(c.groupCreating.isFalse) - // : () => c.startSearch(), - // decorator: (child) { - // return Container( - // padding: - // const EdgeInsets.only(left: 20, right: 6), - // width: 46, - // height: double.infinity, - // child: child, - // ); - // }, - // child: Center( - // child: c.groupCreating.value && c.searching.value - // ? const SvgIcon(SvgIcons.back) - // : const SvgIcon(SvgIcons.search), - // ), - // ); - // } + if (c.searching.value || c.groupCreating.value) { + return AnimatedButton( + key: c.searching.value + ? const Key('CloseSearchButton') + : const Key('SearchButton'), + onPressed: c.searching.value + ? () => c.closeSearch(c.groupCreating.isFalse) + : () => c.startSearch(), + decorator: (child) { + return Container( + padding: + const EdgeInsets.only(left: 20, right: 6), + width: 46, + height: double.infinity, + child: child, + ); + }, + child: Center( + child: c.groupCreating.value && c.searching.value + ? const SvgIcon(SvgIcons.back) + : const SvgIcon(SvgIcons.search), + ), + ); + } return const SizedBox(width: 21); }), diff --git a/lib/ui/page/link/view.dart b/lib/ui/page/link/view.dart index 2873d9d9f05..8819766d46d 100644 --- a/lib/ui/page/link/view.dart +++ b/lib/ui/page/link/view.dart @@ -15,18 +15,12 @@ // along with this program. If not, see // . -import 'package:animated_size_and_fade/animated_size_and_fade.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '/config.dart'; import '/l10n/l10n.dart'; -import '/themes.dart'; import '/ui/page/home/widget/direct_link.dart'; import '/ui/widget/modal_popup.dart'; -import '/ui/widget/primary_button.dart'; -import '/ui/widget/text_field.dart'; -import '/ui/widget/widget_button.dart'; import '/util/platform_utils.dart'; import 'controller.dart'; @@ -43,8 +37,6 @@ class LinkView extends StatelessWidget { @override Widget build(BuildContext context) { - final style = Theme.of(context).style; - return GetBuilder( init: LinkController( Get.find(), @@ -53,117 +45,36 @@ class LinkView extends StatelessWidget { pop: context.popModal, ), builder: (LinkController c) { - return Obx(() { - final Widget header; - final List children; - - switch (c.screen.value) { - case LinkScreen.link: - header = ModalPopupHeader(text: 'label_your_direct_link'.l10n); - children = [ - const SizedBox(height: 16), - Obx(() { - return DirectLinkField( - c.myUser.value?.chatDirectLink, - onSubmit: (s) async { - if (s == null) { - await c.deleteChatDirectLink(); - } else { - await c.createChatDirectLink(s); - } - }, - background: c.background.value, - ); - }), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: Container( - width: double.infinity, - height: 0.5, - color: style.colors.onBackgroundOpacity27, - ), - ), - const SizedBox(width: 8), - Text( - 'label_or'.l10n, - style: style.fonts.small.regular.secondary, - ), - const SizedBox(width: 8), - Expanded( - child: Container( - width: double.infinity, - height: 0.5, - color: style.colors.onBackgroundOpacity27, - ), - ), - ], - ), - const SizedBox(height: 16), - Center( - child: WidgetButton( - key: const Key('SaveLinkButton'), - onPressed: () => c.screen.value = LinkScreen.input, - child: Text( - 'btn_or_input_someones_link'.l10n, - style: style.fonts.small.regular.primary, - ), - ), - ), - const SizedBox(height: 16), - ]; - break; - - case LinkScreen.input: - header = ModalPopupHeader( - text: 'label_direct_chat_link'.l10n, - onBack: () => c.screen.value = LinkScreen.link, - ); - children = [ - const SizedBox(height: 25), - ReactiveTextField( - state: c.link, - label: 'label_direct_chat_link'.l10n, - floatingLabelBehavior: FloatingLabelBehavior.always, - hint: '${Config.link}/...', - ), - const SizedBox(height: 25), - Obx(() { - final bool enabled = !c.link.isEmpty.value && - c.link.status.value.isEmpty && - (c.link.error.value == null || - c.link.resubmitOnError.value); - - return PrimaryButton( - title: 'btn_proceed'.l10n, - onPressed: enabled ? c.openLink : null, - ); - }), - const SizedBox(height: 16), - ]; - break; - } - - return AnimatedSizeAndFade( - fadeDuration: const Duration(milliseconds: 250), - sizeDuration: const Duration(milliseconds: 250), - child: Column( - key: Key(c.screen.value.toString()), - mainAxisSize: MainAxisSize.min, - children: [ - header, - Flexible( - child: ListView( - padding: ModalPopup.padding(context), - shrinkWrap: true, - children: children, - ), - ), - ], + return Column( + key: Key(c.screen.value.toString()), + mainAxisSize: MainAxisSize.min, + children: [ + ModalPopupHeader(text: 'label_your_direct_link'.l10n), + Flexible( + child: ListView( + padding: ModalPopup.padding(context), + shrinkWrap: true, + children: [ + const SizedBox(height: 16), + Obx(() { + return DirectLinkField( + c.myUser.value?.chatDirectLink, + onSubmit: (s) async { + if (s == null) { + await c.deleteChatDirectLink(); + } else { + await c.createChatDirectLink(s); + } + }, + background: c.background.value, + ); + }), + const SizedBox(height: 16), + ], + ), ), - ); - }); + ], + ); }, ); } From 581d0a7e495a87e7da6ce3e6ebe41a017dd7c16b Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Thu, 23 May 2024 18:29:11 +0300 Subject: [PATCH 81/88] Improve bot workflow --- assets/icons/arrow_right_blue.svg | 1 + assets/icons/next.svg | 1 + lib/domain/model/chat_item.dart | 8 +- lib/ui/page/home/page/chat/controller.dart | 91 +++++++++++--- .../page/chat/message_field/controller.dart | 25 ++-- .../page/home/page/chat/message_field/desc.js | 90 +++++++++----- .../home/page/chat/message_field/view.dart | 17 +-- .../chat/message_field/widget/buttons.dart | 15 ++- .../message_field/widget/chat_button.dart | 23 ++-- .../message_field/widget/more_button.dart | 116 +++++++++++++++++- lib/ui/page/home/page/chat/view.dart | 1 + .../page/home/page/chat/widget/chat_item.dart | 32 ++++- 12 files changed, 337 insertions(+), 83 deletions(-) create mode 100644 assets/icons/arrow_right_blue.svg create mode 100644 assets/icons/next.svg diff --git a/assets/icons/arrow_right_blue.svg b/assets/icons/arrow_right_blue.svg new file mode 100644 index 00000000000..53d574f5160 --- /dev/null +++ b/assets/icons/arrow_right_blue.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/next.svg b/assets/icons/next.svg new file mode 100644 index 00000000000..4a4994bf8b9 --- /dev/null +++ b/assets/icons/next.svg @@ -0,0 +1 @@ + diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index bdcd37dd075..16fe100800b 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -381,6 +381,7 @@ class BotInfo extends ChatItem { this.text, this.actions, this.rfw, + this.meta, }); static BotInfo? parse(ChatMessage msg) { @@ -409,6 +410,7 @@ class BotInfo extends ChatItem { text: text == null ? null : ChatMessageText(text), repliesTo: msg.repliesTo.firstOrNull, rfw: decoded['rfw'], + meta: decoded['meta'], actions: (actions as List?)?.map((e) { return BotAction( text: e['text'], @@ -429,6 +431,7 @@ class BotInfo extends ChatItem { ChatMessageText? text; List? actions; + Map? meta; String title; final String? rfw; @@ -477,7 +480,7 @@ class BotMenu { final more = json[L10n.chosen.value!.toString()]?['more'] ?? json['more']; return BotMenu( - text: json['text'], + text: json['text'] ?? '', command: json['command'], icon: json['icon'], action: json['action'] == null @@ -504,6 +507,7 @@ class BotMenuAction { required this.type, required this.command, this.description, + this.meta, this.rfw, }); @@ -512,6 +516,7 @@ class BotMenuAction { type: json['type'], command: json['command'], description: json['description'], + meta: json['meta'], rfw: json['rfw'], ); } @@ -519,5 +524,6 @@ class BotMenuAction { final String? type; final String? command; final String? description; + final Map? meta; final String? rfw; } diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 0132da020d1..b215e092348 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -30,6 +30,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_list_view/flutter_list_view.dart'; import 'package:get/get.dart'; +import 'package:messenger/domain/service/my_user.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '/api/backend/schema.dart' @@ -113,6 +114,7 @@ class ChatController extends GetxController { this._callService, this._authService, this._userService, + this._myUserService, this._settingsRepository, this._contactService, { this.itemId, @@ -307,6 +309,8 @@ class ChatController extends GetxController { /// [AuthService] used to get [me] value. final AuthService _authService; + final MyUserService _myUserService; + /// [User]s service fetching the [User]s in [getUser] method. final UserService _userService; @@ -1165,7 +1169,7 @@ class ChatController extends GetxController { title: 'Donate', text: '\$${sum.toStringAsFixed(2)}', rfw: - "import core.widgets; widget root = Container( constraints: { 'minWidth': 300.0 }, height: 104.0, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Center( child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Text( text: [data.description], textDirection: 'ltr', ), ), ), ), );", + "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", ), ), repliesTo: [repliesTo], @@ -1181,16 +1185,22 @@ class ChatController extends GetxController { await _chatService.sendChatMessage( id, text: ChatMessageText.bot( - localized: { - const Locale('en', 'US'): ChatBotText( - title: 'Donate', - text: 'Donated \$${sum.toStringAsFixed(2)}', - ), - const Locale('ru', 'RU'): ChatBotText( - title: 'Донат', - text: 'Отправлен донат \$${sum.toStringAsFixed(2)}', - ), - }, + text: ChatBotText( + title: 'Donate', + text: '\$${sum.toStringAsFixed(2)}', + rfw: + "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", + ), + // localized: { + // const Locale('en', 'US'): ChatBotText( + // title: 'Donate', + // text: 'Donated \$${sum.toStringAsFixed(2)}', + // ), + // const Locale('ru', 'RU'): ChatBotText( + // title: 'Донат', + // text: 'Отправлен донат \$${sum.toStringAsFixed(2)}', + // ), + // }, ), ); } @@ -2211,9 +2221,22 @@ class ChatController extends GetxController { 0, botInfo.value!.more.map( (e) { - return e.toChatButton(onPressed: (a) { + return e.toChatButton(onPressed: (a, args) { + String? withArgs(String? str) { + if (str == null) { + return null; + } + + if (args?.containsKey('number') == true) { + return str.replaceAll('\$input', + (args!['number']! as double).toStringAsFixed(2)); + } + + return str; + } + if (a.command != null) { - postCommand(a.command!); + postCommand(withArgs(a.command!)!); } else if (a.action != null) { switch (a.action?.type) { case 'attach': @@ -2221,7 +2244,12 @@ class ChatController extends GetxController { send.actions.add( RfwAttachment( a.action?.rfw ?? e.rfw, - description: a.action?.description, + description: { + ...a.action?.meta ?? {}, + 'name': _myUserService.myUser.value?.name?.val ?? + _myUserService.myUser.value?.num.toString(), + 'description': withArgs(a.action?.description), + }, command: a.action?.command, ), ); @@ -2806,13 +2834,40 @@ class _ListViewIndexCalculationResult { } extension on BotMenu { - CustomChatButton toChatButton({void Function(BotMenu)? onPressed}) { + CustomChatButton toChatButton({ + void Function(BotMenu, Map? arguments)? onPressed, + BotMenu? parent, + String? parentIcon, + }) { + CustomInputParameters? input; + + if (text.startsWith('\$input(')) { + double min = 0; + if (text.contains('min: ')) { + min = double.tryParse( + text.substring(text.indexOf('min: ') + 4, text.lastIndexOf(')')), + ) ?? + 0; + } + + input = CustomInputParameters(min: min); + } + return CustomChatButton( hint: text, - icon: icon, - onPressed: onPressed == null ? null : () => onPressed(this), - buttons: more.map((e) => e.toChatButton(onPressed: onPressed)).toList(), + icon: icon ?? parent?.icon ?? parentIcon, + onPressed: onPressed == null ? null : (args) => onPressed(this, args), + buttons: more + .map( + (e) => e.toChatButton( + onPressed: onPressed, + parentIcon: icon ?? parent?.icon ?? parentIcon, + parent: this, + ), + ) + .toList(), trailing: trailing?.toChatButton(onPressed: onPressed), + input: input, ); } } diff --git a/lib/ui/page/home/page/chat/message_field/controller.dart b/lib/ui/page/home/page/chat/message_field/controller.dart index 2e82e70daac..17b77fe3bed 100644 --- a/lib/ui/page/home/page/chat/message_field/controller.dart +++ b/lib/ui/page/home/page/chat/message_field/controller.dart @@ -215,8 +215,8 @@ class MessageFieldController extends GetxController { AttachmentButton(pickFile), if (_settings?.value?.callButtonsPosition == CallButtonsPosition.more && onCall != null) ...[ - AudioCallButton(() => onCall?.call(false)), - VideoCallButton(() => onCall?.call(true)), + AudioCallButton((_) => onCall?.call(false)), + VideoCallButton((_) => onCall?.call(true)), ], ]); @@ -364,7 +364,7 @@ class MessageFieldController extends GetxController { /// Opens a media choose popup and adds the selected files to the /// [attachments]. - Future pickMedia() { + Future pickMedia(Map? _) { field.focus.unfocus(); return _pickAttachment( PlatformUtils.isIOS ? FileType.media : FileType.image, @@ -372,7 +372,7 @@ class MessageFieldController extends GetxController { } /// Opens the camera app and adds the captured image to the [attachments]. - Future pickImageFromCamera() async { + Future pickImageFromCamera(Map? _) async { field.focus.unfocus(); // TODO: Remove the limitations when bigger files are supported on backend. @@ -389,7 +389,7 @@ class MessageFieldController extends GetxController { } /// Opens the camera app and adds the captured video to the [attachments]. - Future pickVideoFromCamera() async { + Future pickVideoFromCamera(Map? _) async { field.focus.unfocus(); // TODO: Remove the limitations when bigger files are supported on backend. @@ -405,7 +405,7 @@ class MessageFieldController extends GetxController { /// Opens a file choose popup and adds the selected files to the /// [attachments]. - Future pickFile() { + Future pickFile(Map? _) { field.focus.unfocus(); return _pickAttachment(FileType.any); } @@ -488,11 +488,11 @@ class MessageFieldController extends GetxController { void _updateButtons(bool inCall) { panel.value = panel.map((button) { if (button is AudioCallButton) { - return AudioCallButton(inCall ? null : () => onCall?.call(false)); + return AudioCallButton(inCall ? null : (_) => onCall?.call(false)); } if (button is VideoCallButton) { - return VideoCallButton(inCall ? null : () => onCall?.call(true)); + return VideoCallButton(inCall ? null : (_) => onCall?.call(true)); } return button; @@ -524,12 +524,17 @@ class RfwAttachment { runtime?.update(coreName, createCoreWidgets()); runtime?.update(mainName, parseLibraryFile(rfw)); - data?.update('description', description ?? '\$1.00'); + + if (description != null) { + for (var d in description!.entries) { + data?.update(d.key, d.value); + } + } } } final String id = const Uuid().v4(); - final String? description; + final Map? description; final String? command; Runtime? runtime; diff --git a/lib/ui/page/home/page/chat/message_field/desc.js b/lib/ui/page/home/page/chat/message_field/desc.js index 13cb2434053..5525e06c37f 100644 --- a/lib/ui/page/home/page/chat/message_field/desc.js +++ b/lib/ui/page/home/page/chat/message_field/desc.js @@ -2,6 +2,7 @@ import core.widgets; widget root = Container( height: 104.0, + constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', @@ -27,38 +28,65 @@ widget root = Container( stops: [0.0, 0.32, 0.68, 1.0], }, }, - child: Center( - child: DefaultTextStyle( - style: { - color: 0xFFF3CD01, - fontSize: 32.0, - shadows: [ - { - offset: { x: 1.0, y: 1.0 }, - blurRadius: 3.0, - color: 0xE4AC9200, - }, - { - offset: { x: -1.0, y: -1.0 }, - blurRadius: 2.0, - color: 0xE4FFFF00, - }, - { - offset: { x: 1.0, y: -1.0 }, - blurRadius: 2.0, - color: 0x33AC9200, - }, - { - offset: { x: -1.0, y: 1.0 }, - blurRadius: 2.0, - color: 0x33AC9200, - }, - ], - }, - child: Text( - text: [data.description], - textDirection: 'ltr', + child: DefaultTextStyle( + style: { + color: 0xFFF3CD01, + fontSize: 32.0, + shadows: [ + { + offset: { x: 1.0, y: 1.0 }, + blurRadius: 3.0, + color: 0xE4AC9200, + }, + { + offset: { x: -1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0xE4FFFF00, + }, + { + offset: { x: 1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + { + offset: { x: -1.0, y: 1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + ], + }, + child: Stack( + children: [ + Align( + alignment: { x: -1.0, y: -1.0 }, + child: Padding( + padding: [4.0, 4.0], + child: Text( + text: [data.name], + style: { fontSize: 17.0 }, + textDirection: 'ltr', + ), + ), + ), + Align( + alignment: { x: 1.0, y: 1.0 }, + child: Padding( + padding: [4.0, 4.0], + child: Text( + text: 'DONATION', + style: { fontSize: 13.0 }, + textDirection: 'ltr', + ), + ), + ), + Center( + child: Text( + text: [data.description], + textDirection: 'ltr', + + ), ), + ], ), ), ), diff --git a/lib/ui/page/home/page/chat/message_field/view.dart b/lib/ui/page/home/page/chat/message_field/view.dart index b0dad605025..45e4f841564 100644 --- a/lib/ui/page/home/page/chat/message_field/view.dart +++ b/lib/ui/page/home/page/chat/message_field/view.dart @@ -101,7 +101,7 @@ class MessageFieldView extends StatelessWidget { final List more; /// Returns a [ThemeData] to decorate a [ReactiveTextField] with. - static ThemeData theme(BuildContext context) { + static ThemeData theme(BuildContext context, {EdgeInsets? contentPadding}) { final style = Theme.of(context).style; final OutlineInputBorder border = OutlineInputBorder( @@ -124,12 +124,13 @@ class MessageFieldView extends StatelessWidget { hoverColor: style.colors.transparent, filled: true, isDense: true, - contentPadding: EdgeInsets.fromLTRB( - 15, - PlatformUtils.isDesktop ? 30 : 23, - 15, - 0, - ), + contentPadding: contentPadding ?? + EdgeInsets.fromLTRB( + 15, + PlatformUtils.isDesktop ? 30 : 23, + 15, + 0, + ), ), ); } @@ -519,7 +520,7 @@ class MessageFieldView extends StatelessWidget { ? const Key('Forward') : sendKey ?? const Key('Send'), forwarding: c.forwarding.value, - onPressed: c.field.submit, + onPressed: (_) => c.field.submit(), onLongPress: canForward ? c.forwarding.toggle : null, ), ); diff --git a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart index 62f32d99a4c..17a796cded4 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart @@ -27,7 +27,7 @@ abstract class ChatButton { const ChatButton([this.onPressed]); /// Callback, called when this [ChatButton] is pressed. - final void Function()? onPressed; + final void Function(Map? arguments)? onPressed; /// Returns a text-represented hint for this [ChatButton]. String get hint; @@ -52,6 +52,8 @@ abstract class ChatButton { List get buttons => const []; ChatButton? get trailing => null; + CustomInputParameters? get input => null; + @override int get hashCode => runtimeType.hashCode; @@ -233,11 +235,12 @@ class VideoCallButton extends ChatButton { /// Custom [ChatButton]. class CustomChatButton extends ChatButton { const CustomChatButton({ - void Function()? onPressed, + void Function(Map? arguments)? onPressed, required this.hint, this.icon, this.buttons = const [], this.trailing, + this.input, }) : super(onPressed); @override @@ -254,4 +257,12 @@ class CustomChatButton extends ChatButton { @override final ChatButton? trailing; + + @override + final CustomInputParameters? input; +} + +class CustomInputParameters { + const CustomInputParameters({this.min = 0}); + final double min; } diff --git a/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart b/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart index 8e6f2716121..03c53445503 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/chat_button.dart @@ -27,7 +27,9 @@ class ChatButtonWidget extends StatelessWidget { ChatButtonWidget( ChatButton button, { super.key, - void Function()? onPressed, + this.enabled = true, + this.height = 56, + void Function(Map? arguments)? onPressed, }) : onPressed = onPressed ?? button.onPressed, onLongPress = null, icon = Transform.translate( @@ -38,7 +40,9 @@ class ChatButtonWidget extends StatelessWidget { ), disabledIcon = Transform.translate( offset: button.offset, - child: SvgIcon(button.disabled ?? button.asset), + child: button.icon == null + ? SvgIcon(button.disabled ?? button.asset) + : Opacity(opacity: 0.7, child: SvgImage.network(button.icon!)), ); /// Constructs a send/forward [ChatButtonWidget]. @@ -48,10 +52,12 @@ class ChatButtonWidget extends StatelessWidget { this.onPressed, this.onLongPress, }) : icon = SvgIcon(forwarding ? SvgIcons.forward : SvgIcons.send), - disabledIcon = null; + enabled = true, + disabledIcon = null, + height = 56; /// Callback, called when this [ChatButtonWidget] is pressed. - final void Function()? onPressed; + final void Function(Map? arguments)? onPressed; /// Callback, called when this [ChatButtonWidget] is long-pressed. final void Function()? onLongPress; @@ -62,17 +68,20 @@ class ChatButtonWidget extends StatelessWidget { /// Disabled icon to display. final Widget? disabledIcon; + final bool enabled; + final double height; + @override Widget build(BuildContext context) { - final bool disabled = onPressed == null; + final bool disabled = !enabled || onPressed == null; return AnimatedButton( - onPressed: disabled ? null : onPressed, + onPressed: disabled ? null : () => onPressed?.call(null), onLongPress: onLongPress, enabled: !disabled, child: SizedBox( width: 50, - height: 56, + height: height, child: Center(child: disabled ? (disabledIcon ?? icon) : icon), ), ); diff --git a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart index d87ca1b2540..d20a9610e7d 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart @@ -17,7 +17,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:messenger/ui/widget/text_field.dart'; +import '../controller.dart'; import '/themes.dart'; import '/ui/widget/animated_button.dart'; import '/ui/widget/animated_switcher.dart'; @@ -30,7 +32,7 @@ import 'chat_button.dart'; class ChatMoreWidget extends StatefulWidget { /// Constructs a [ChatMoreWidget] from the provided [ChatButton]. ChatMoreWidget( - ChatButton button, { + this.button, { super.key, this.pinned = false, this.onPin, @@ -44,8 +46,10 @@ class ChatMoreWidget extends StatefulWidget { onDismiss = onPressed, onPressed = button.onPressed; + final ChatButton button; + /// Callback, called when this [ChatMoreWidget] is pressed. - late final void Function()? onPressed; + late final void Function(Map? args)? onPressed; /// Indicator whether this [ChatMoreWidget] is pinned. final bool? pinned; @@ -75,10 +79,111 @@ class _ChatMoreWidgetState extends State { /// Indicator whether this [ChatMoreWidget] is hovered. bool _hovered = false; + final GlobalKey _fieldState = GlobalKey(); + final TextFieldState _state = TextFieldState(); + @override Widget build(BuildContext context) { final style = Theme.of(context).style; + if (widget.button.input != null) { + return Obx(() { + final double sum = double.tryParse(_state.text) ?? 0; + final bool disabled = + _state.isEmpty.value || sum < widget.button.input!.min; + + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 48), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 16), + SizedBox( + width: 26, + child: WidgetButton( + onPressed: disabled + ? null + : () { + widget.onPressed?.call({'number': sum}); + widget.onDismiss?.call(); + }, + behavior: HitTestBehavior.deferToChild, + child: AnimatedScale( + duration: const Duration(milliseconds: 100), + scale: (_hovered && !disabled) ? 1.05 : 1, + child: Transform.translate( + offset: widget.offset, + child: Opacity( + opacity: disabled ? 0.6 : 1, + child: widget.icon, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 56, + child: Theme( + data: MessageFieldView.theme(context), + child: ReactiveTextField( + key: _fieldState, + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + state: _state, + hint: 'Min: ${widget.button.input?.min}', + style: style.fonts.medium.regular.primary, + ), + ), + ), + ), + const SizedBox(width: 16), + if (widget.trailing != null) + ChatButtonWidget( + widget.trailing!, + enabled: !disabled, + height: 36, + onPressed: (args) { + widget.trailing?.onPressed + ?.call({...args ?? {}, 'number': sum}); + widget.onDismiss?.call(); + }, + ), + if (widget.pinned != null) ...[ + WidgetButton( + onPressed: widget.onPin ?? () {}, + child: SizedBox( + height: 40, + width: 40, + child: Center( + child: AnimatedButton( + child: SafeAnimatedSwitcher( + duration: 100.milliseconds, + child: widget.pinned! + ? const SvgIcon( + SvgIcons.unpin, + key: Key('Unpin'), + ) + : Opacity( + key: const Key('Pin'), + opacity: widget.onPin == null || disabled + ? 0.6 + : 1, + child: const SvgIcon(SvgIcons.pin), + ), + ), + ), + ), + ), + ), + ], + ], + ), + ); + }); + } + final bool disabled = widget.onPressed == null; return IgnorePointer( @@ -91,7 +196,7 @@ class _ChatMoreWidgetState extends State { onPressed: disabled ? null : () { - widget.onPressed?.call(); + widget.onPressed?.call(null); widget.onDismiss?.call(); }, behavior: HitTestBehavior.deferToChild, @@ -131,8 +236,9 @@ class _ChatMoreWidgetState extends State { if (widget.trailing != null) ChatButtonWidget( widget.trailing!, - onPressed: () { - widget.trailing?.onPressed?.call(); + height: 36, + onPressed: (args) { + widget.trailing?.onPressed?.call(args); widget.onDismiss?.call(); }, ), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 08cd9dffe99..c3b00fea72d 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -96,6 +96,7 @@ class ChatView extends StatelessWidget { Get.find(), Get.find(), Get.find(), + Get.find(), itemId: itemId, welcome: welcome, onContext: () => context, diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index a59e69db5d0..5506d6ac371 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -459,6 +459,14 @@ class _ChatItemWidgetState extends State { } if (_bot != null) { + if (_rfwAttachment != null) { + return _rounded( + context, + (_, __) { + return _rfw(context, _rfwAttachment!); + }, + ); + } return _renderAsBotInfo(context); } @@ -2020,6 +2028,7 @@ class _ChatItemWidgetState extends State { } RxList _rfws = RxList(); + RfwAttachment? _rfwAttachment; /// Populates the [_worker] invoking the [_populateSpans] and /// [_populateGlobalKeys] on the [ChatItemWidget.item] changes. @@ -2050,6 +2059,17 @@ class _ChatItemWidgetState extends State { } else if (msg.repliesTo.isEmpty && (text?.val.startsWith('[@bot]') ?? false)) { _bot = BotInfo.parse(msg); + if (_bot?.rfw != null) { + _rfwAttachment = RfwAttachment( + _bot?.rfw, + description: { + ..._bot?.meta ?? {}, + 'name': widget.user?.user.value.name?.val ?? + widget.user?.user.value.num.toString(), + 'description': _bot?.text?.val, + }, + ); + } } } @@ -2077,7 +2097,17 @@ class _ChatItemWidgetState extends State { _rfws.clear(); for (var e in widget.infos) { if (e.rfw != null) { - _rfws.add(RfwAttachment(e.rfw!, description: e.text?.val)); + _rfws.add( + RfwAttachment( + e.rfw!, + description: { + ...e.meta ?? {}, + 'name': widget.user?.user.value.name?.val ?? + widget.user?.user.value.num.toString(), + 'description': e.text?.val, + }, + ), + ); } } } From 3fd55cedc71b3db9b807154324fe36b8ef3c7de9 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 24 May 2024 17:45:48 +0300 Subject: [PATCH 82/88] Add `BotBar` description --- assets/icons/dollar.svg | 1 + lib/domain/model/chat_item.dart | 37 +++++- lib/ui/page/home/page/chat/controller.dart | 112 +++++++++++------- .../chat/message_field/widget/buttons.dart | 4 +- .../message_field/widget/more_button.dart | 5 +- lib/ui/page/home/page/chat/view.dart | 62 ++++++++++ .../home/page/chat/widget/disclaimer.dart | 96 +++++++++++++++ lib/ui/page/home/view.dart | 3 +- lib/ui/widget/markdown.dart | 3 + .../safe_area_insets/safe_area_insets.dart | 18 +++ .../widget/safe_area_insets/src/non_web.dart | 31 +++++ lib/ui/widget/safe_area_insets/src/web.dart | 32 +++++ pubspec.lock | 8 ++ pubspec.yaml | 1 + 14 files changed, 361 insertions(+), 52 deletions(-) create mode 100644 assets/icons/dollar.svg create mode 100644 lib/ui/page/home/page/chat/widget/disclaimer.dart create mode 100644 lib/ui/widget/safe_area_insets/safe_area_insets.dart create mode 100644 lib/ui/widget/safe_area_insets/src/non_web.dart create mode 100644 lib/ui/widget/safe_area_insets/src/web.dart diff --git a/assets/icons/dollar.svg b/assets/icons/dollar.svg new file mode 100644 index 00000000000..a09c3e57c60 --- /dev/null +++ b/assets/icons/dollar.svg @@ -0,0 +1 @@ + diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index 16fe100800b..cdcbd7940c1 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -418,7 +418,7 @@ class BotInfo extends ChatItem { icon: e['icon'], ); }).toList(), - title: title ?? 'Bot', + title: title, ); } } @@ -433,7 +433,7 @@ class BotInfo extends ChatItem { List? actions; Map? meta; - String title; + String? title; final String? rfw; } @@ -527,3 +527,36 @@ class BotMenuAction { final Map? meta; final String? rfw; } + +class BotBar { + const BotBar({this.header, this.info, this.icon}); + + factory BotBar.fromJson( + Map json, { + Map args = const {}, + }) { + String? info = json['info']; + for (var e in args.entries) { + info = info?.replaceAll('\$${e.key}', e.value); + } + + String? header = json['header']; + for (var e in args.entries) { + header = header?.replaceAll('\$${e.key}', e.value); + } + + return BotBar(info: info, header: header, icon: json['icon']); + } + + Map toJson() { + return { + if (icon != null) 'info': info, + if (header != null) 'header': header, + if (icon != null) 'icon': icon, + }; + } + + final String? info; + final String? header; + final String? icon; +} diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index b215e092348..90181a58537 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -32,6 +32,7 @@ import 'package:flutter_list_view/flutter_list_view.dart'; import 'package:get/get.dart'; import 'package:messenger/domain/service/my_user.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uuid/uuid.dart'; import '/api/backend/schema.dart' hide @@ -463,42 +464,53 @@ class ChatController extends GetxController { } else { if (send.field.text.trim().isNotEmpty || send.attachments.isNotEmpty || - send.replied.isNotEmpty) { + send.replied.isNotEmpty || + send.actions.isNotEmpty) { final List actions = send.actions.toList(); - _chatService - .sendChatMessage( - chat?.chat.value.id ?? id, - text: send.field.text.trim().isEmpty - ? null - : ChatMessageText(send.field.text.trim()), - repliesTo: send.replied.map((e) => e.value).toList(), - attachments: send.attachments.map((e) => e.value).toList(), - ) - .then( - (item) { - AudioUtils.once( - AudioSource.asset('audio/message_sent.mp3'), - ); - - if (item != null) { - for (var e in actions) { - if (e.command != null) { - postCommand(e.command!, repliesTo: item); + if (send.field.text.trim().isNotEmpty || + send.attachments.isNotEmpty || + send.replied.isNotEmpty) { + _chatService + .sendChatMessage( + chat?.chat.value.id ?? id, + text: send.field.text.trim().isEmpty + ? null + : ChatMessageText(send.field.text.trim()), + repliesTo: send.replied.map((e) => e.value).toList(), + attachments: send.attachments.map((e) => e.value).toList(), + ) + .then( + (item) { + AudioUtils.once( + AudioSource.asset('audio/message_sent.mp3'), + ); + + if (item != null) { + for (var e in actions) { + if (e.command != null) { + postCommand(e.command!, repliesTo: item); + } } } + }, + ).onError( + (_, __) { + _showBlockedPopup(); + }, + test: (e) => e.code == PostChatMessageErrorCode.blocked, + ).onError( + (e, _) { + MessagePopup.error(e); + }, + ).onError((e, _) {}); + } else if (actions.isNotEmpty) { + for (var e in actions) { + if (e.command != null) { + postCommand(e.command!); } - }, - ).onError( - (_, __) { - _showBlockedPopup(); - }, - test: (e) => e.code == PostChatMessageErrorCode.blocked, - ).onError( - (e, _) { - MessagePopup.error(e); - }, - ).onError((e, _) {}); + } + } send.clear(unfocus: false); @@ -1166,7 +1178,6 @@ class ChatController extends GetxController { id, text: ChatMessageText.bot( text: ChatBotText( - title: 'Donate', text: '\$${sum.toStringAsFixed(2)}', rfw: "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", @@ -1186,7 +1197,6 @@ class ChatController extends GetxController { id, text: ChatMessageText.bot( text: ChatBotText( - title: 'Donate', text: '\$${sum.toStringAsFixed(2)}', rfw: "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", @@ -1924,7 +1934,7 @@ class ChatController extends GetxController { elements.entries.where((e) => e.key.id == quote.original?.id); for (var e in items.map((e) => e.value)) { if (e is ChatMessageElement) { - e.infos[info!.title] = info; + e.infos[info!.title ?? const Uuid().v4()] = info; } } } @@ -2184,6 +2194,8 @@ class ChatController extends GetxController { } final Rx botInfo = Rx(null); + final RxBool showInfo = RxBool(false); + final Rx infoAlignment = Rx(Alignment.topCenter); void _addBotInfo(RxUser e) { Log.info('_addBotInfo($e)'); @@ -2205,6 +2217,8 @@ class ChatController extends GetxController { decoded?['actions']; final more = decoded?[L10n.chosen.value!.toString()]?['more'] ?? decoded?['more']; + final bar = + decoded?[L10n.chosen.value!.toString()]?['bar'] ?? decoded?['bar']; botInfo.value = BotInfoElement( text, @@ -2213,8 +2227,20 @@ class ChatController extends GetxController { (actions as List?)?.map((e) => BotAction.fromJson(e)).toList() ?? [], more: (more as List?)?.map((e) => BotMenu.fromJson(e)).toList() ?? [], + bar: bar == null + ? null + : BotBar.fromJson( + bar, + args: { + if (user != null) 'name': user?.title, + }, + ), ); + if (botInfo.value?.bar != null) { + showInfo.value = true; + } + Log.info('more: ${botInfo.value!.more}'); send.panel.insertAll( @@ -2550,7 +2576,9 @@ class ChatMessageElement extends ListElement { List infos = const [], }) : infos = RxMap.from( Map.fromEntries( - infos.map((e) => MapEntry(e.title, e)).toList(), + infos + .map((e) => MapEntry(e.title ?? const Uuid().v4(), e)) + .toList(), ), ), super(ListElementId(item.value.at, item.value.id)); @@ -2638,6 +2666,7 @@ class BotInfoElement extends ListElement { required PreciseDateTime at, this.actions = const [], this.more = const [], + this.bar, }) : super(ListElementId(at, const ChatItemId('0'))); /// [String] of this [BotInfoElement]. @@ -2645,6 +2674,7 @@ class BotInfoElement extends ListElement { final List actions; final List more; + final BotBar? bar; } /// [ListElement] representing a [ChatInfo]. @@ -2842,15 +2872,9 @@ extension on BotMenu { CustomInputParameters? input; if (text.startsWith('\$input(')) { - double min = 0; - if (text.contains('min: ')) { - min = double.tryParse( - text.substring(text.indexOf('min: ') + 4, text.lastIndexOf(')')), - ) ?? - 0; - } - - input = CustomInputParameters(min: min); + input = CustomInputParameters( + hint: text.substring(text.indexOf('(') + 1, text.lastIndexOf(')')), + ); } return CustomChatButton( diff --git a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart index 17a796cded4..878fbb0dadd 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/buttons.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/buttons.dart @@ -263,6 +263,6 @@ class CustomChatButton extends ChatButton { } class CustomInputParameters { - const CustomInputParameters({this.min = 0}); - final double min; + const CustomInputParameters({this.hint}); + final String? hint; } diff --git a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart index d20a9610e7d..757e4bcfd76 100644 --- a/lib/ui/page/home/page/chat/message_field/widget/more_button.dart +++ b/lib/ui/page/home/page/chat/message_field/widget/more_button.dart @@ -89,8 +89,7 @@ class _ChatMoreWidgetState extends State { if (widget.button.input != null) { return Obx(() { final double sum = double.tryParse(_state.text) ?? 0; - final bool disabled = - _state.isEmpty.value || sum < widget.button.input!.min; + final bool disabled = _state.isEmpty.value; return Container( width: double.infinity, @@ -132,7 +131,7 @@ class _ChatMoreWidgetState extends State { key: _fieldState, padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), state: _state, - hint: 'Min: ${widget.button.input?.min}', + hint: '${widget.button.input?.hint}', style: style.fonts.medium.regular.primary, ), ), diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index c3b00fea72d..84d81085633 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -65,6 +65,7 @@ import 'widget/chat_item.dart'; import 'widget/chat_subtitle.dart'; import 'widget/circle_button.dart'; import 'widget/custom_drop_target.dart'; +import 'widget/disclaimer.dart'; import 'widget/time_label.dart'; import 'widget/unread_label.dart'; @@ -296,6 +297,31 @@ class ChatView extends StatelessWidget { return Row( children: [ + Obx(() { + final BotBar? bar = c.botInfo.value?.bar; + if (bar == null) { + return const SizedBox(); + } + + return AnimatedOpacity( + opacity: c.showInfo.value ? 0 : 1, + duration: 300.milliseconds, + child: AnimatedButton( + enabled: !c.showInfo.value, + onPressed: c.showInfo.toggle, + decorator: (child) => Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 8, + 28, + 8, + ), + child: child, + ), + child: SvgImage.network(bar.icon), + ), + ); + }), ...children, Obx(() { final bool muted = @@ -677,6 +703,42 @@ class ChatView extends StatelessWidget { ); }), ), + Obx(() { + final BotBar? bar = c.botInfo.value?.bar; + if (bar == null) { + return const SizedBox(); + } + + final Widget child; + + if (c.showInfo.value) { + child = Padding( + padding: EdgeInsets.only( + top: c.infoAlignment.value.y < 0 ? 8 : 8, + bottom: c.infoAlignment.value.y > 0 ? 8 : 8, + ), + child: DisclaimerWidget( + header: bar.header, + description: bar.info, + onPressed: () => c.showInfo.value = false, + ), + ); + } else { + child = const SizedBox( + key: Key('123'), + width: double.infinity, + ); + } + + return AnimatedAlign( + duration: const Duration( + milliseconds: 300, + ), + curve: Curves.ease, + alignment: c.infoAlignment.value, + child: AnimatedSizeAndFade(child: child), + ); + }), ], ), floatingActionButton: Obx(() { diff --git a/lib/ui/page/home/page/chat/widget/disclaimer.dart b/lib/ui/page/home/page/chat/widget/disclaimer.dart new file mode 100644 index 00000000000..fccd95298f9 --- /dev/null +++ b/lib/ui/page/home/page/chat/widget/disclaimer.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:messenger/l10n/l10n.dart'; +import 'package:messenger/themes.dart'; +import 'package:messenger/ui/widget/widget_button.dart'; + +import '../../../../../widget/markdown.dart'; + +class DisclaimerWidget extends StatelessWidget { + const DisclaimerWidget({ + super.key, + this.border, + this.onPressed, + this.description, + this.action, + this.span, + this.accepted = false, + this.name, + this.header, + }); + + final bool accepted; + final Border? border; + final void Function()? onPressed; + final String? name; + final String? header; + final String? description; + final InlineSpan? span; + final String? action; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).style; + + return Container( + margin: const EdgeInsets.only(top: 0, bottom: 0, left: 8, right: 8), + decoration: BoxDecoration( + borderRadius: style.cardRadius, + boxShadow: const [ + CustomBoxShadow(blurRadius: 8, color: Color(0x22000000)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.fromLTRB( + 18, + 18, + 18, + 18, + ), + decoration: BoxDecoration( + border: border, + borderRadius: style.cardRadius, + color: Colors.white, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (header != null) + Center( + child: MarkdownWidget( + header!, + style: style.systemMessageStyle, + ), + ), + if (header != null && description != null) + const SizedBox(height: 8), + if (description != null) + MarkdownWidget( + description!, + style: style.systemMessageStyle, + ), + if (span != null) + Text.rich(span!, style: style.systemMessageStyle), + const SizedBox(height: 8), + Center( + child: WidgetButton( + onPressed: onPressed, + child: Text( + action ?? 'btn_proceed'.l10n, + style: style.systemMessageStyle + .copyWith(color: style.colors.primary), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/page/home/view.dart b/lib/ui/page/home/view.dart index aeb82d0e723..099a8335c27 100644 --- a/lib/ui/page/home/view.dart +++ b/lib/ui/page/home/view.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:get/get.dart'; import 'package:messenger/ui/page/link/view.dart'; +import 'package:messenger/ui/widget/safe_area_insets/safe_area_insets.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '/domain/model/user.dart'; @@ -211,7 +212,7 @@ class _HomeViewState extends State { ], ), extendBody: true, - bottomNavigationBar: SafeArea( + bottomNavigationBar: SafeAreaInsets( child: Obx(() { final List tabs = c.tabs; diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index 7ae7c152ede..e4c60643361 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -48,6 +48,9 @@ class MarkdownWidget extends StatelessWidget { letterSpacing: 1.2, backgroundColor: style.colors.secondaryHighlight, ), + blockSpacing: 0, + listBulletPadding: EdgeInsets.zero, + blockquotePadding: EdgeInsets.zero, codeblockDecoration: BoxDecoration( color: style.colors.secondaryHighlight, ), diff --git a/lib/ui/widget/safe_area_insets/safe_area_insets.dart b/lib/ui/widget/safe_area_insets/safe_area_insets.dart new file mode 100644 index 00000000000..b94af85618a --- /dev/null +++ b/lib/ui/widget/safe_area_insets/safe_area_insets.dart @@ -0,0 +1,18 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +export 'src/non_web.dart' if (dart.library.html) 'src/web.dart'; diff --git a/lib/ui/widget/safe_area_insets/src/non_web.dart b/lib/ui/widget/safe_area_insets/src/non_web.dart new file mode 100644 index 00000000000..24434ac969d --- /dev/null +++ b/lib/ui/widget/safe_area_insets/src/non_web.dart @@ -0,0 +1,31 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'package:flutter/widgets.dart'; + +/// Wrapper to prevent a default web context menu over its [child]. +class SafeAreaInsets extends StatelessWidget { + const SafeAreaInsets({super.key, required this.child}); + + /// Widget being wrapped. + final Widget child; + + @override + Widget build(BuildContext context) { + return SafeArea(child: child); + } +} diff --git a/lib/ui/widget/safe_area_insets/src/web.dart b/lib/ui/widget/safe_area_insets/src/web.dart new file mode 100644 index 00000000000..cc499386a8e --- /dev/null +++ b/lib/ui/widget/safe_area_insets/src/web.dart @@ -0,0 +1,32 @@ +// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC, +// +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for +// more details. +// +// You should have received a copy of the GNU Affero General Public License v3.0 +// along with this program. If not, see +// . + +import 'package:flutter/widgets.dart'; +import 'package:safe_area_insets/safe_area_insets.dart'; + +/// Wrapper to prevent a default web context menu over its [child]. +class SafeAreaInsets extends StatelessWidget { + const SafeAreaInsets({super.key, required this.child}); + + /// Widget being wrapped. + final Widget child; + + @override + Widget build(BuildContext context) { + return WebSafeAreaInsets(child: child); + } +} diff --git a/pubspec.lock b/pubspec.lock index 39b25e8b186..f0ab1a59c7d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1615,6 +1615,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + safe_area_insets: + dependency: "direct main" + description: + name: safe_area_insets + sha256: "7127fb7fbd7b1f6bef9c5ec028e3d50b95ec8a21abfe83c7b07c92f31962a6eb" + url: "https://pub.dev" + source: hosted + version: "0.1.3" safe_local_storage: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 079d7d69373..ecf828e08f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -113,6 +113,7 @@ dependencies: window_manager: ^0.3.5 windows_taskbar: ^1.1.1 xml: ^6.5.0 + safe_area_insets: ^0.1.3 dev_dependencies: artemis: ^7.13.1 From 0a5d8a1cef3be8940e75202b15c497d4509e2d86 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 24 May 2024 17:59:50 +0300 Subject: [PATCH 83/88] Remove `safe_area_insets` --- lib/ui/widget/safe_area_insets/src/web.dart | 4 ++-- pubspec.lock | 8 -------- pubspec.yaml | 1 - 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/lib/ui/widget/safe_area_insets/src/web.dart b/lib/ui/widget/safe_area_insets/src/web.dart index cc499386a8e..130e5bd84ff 100644 --- a/lib/ui/widget/safe_area_insets/src/web.dart +++ b/lib/ui/widget/safe_area_insets/src/web.dart @@ -16,7 +16,6 @@ // . import 'package:flutter/widgets.dart'; -import 'package:safe_area_insets/safe_area_insets.dart'; /// Wrapper to prevent a default web context menu over its [child]. class SafeAreaInsets extends StatelessWidget { @@ -27,6 +26,7 @@ class SafeAreaInsets extends StatelessWidget { @override Widget build(BuildContext context) { - return WebSafeAreaInsets(child: child); + return SafeArea(child: child); + // return WebSafeAreaInsets(child: child); } } diff --git a/pubspec.lock b/pubspec.lock index f0ab1a59c7d..39b25e8b186 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1615,14 +1615,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" - safe_area_insets: - dependency: "direct main" - description: - name: safe_area_insets - sha256: "7127fb7fbd7b1f6bef9c5ec028e3d50b95ec8a21abfe83c7b07c92f31962a6eb" - url: "https://pub.dev" - source: hosted - version: "0.1.3" safe_local_storage: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ecf828e08f3..079d7d69373 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -113,7 +113,6 @@ dependencies: window_manager: ^0.3.5 windows_taskbar: ^1.1.1 xml: ^6.5.0 - safe_area_insets: ^0.1.3 dev_dependencies: artemis: ^7.13.1 From 3a51dd3f72c7ec72c1bae7332ccb50eda3bd6843 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Sat, 25 May 2024 12:03:19 +0300 Subject: [PATCH 84/88] Replace `btn_proceed` with `btn_ok` --- lib/ui/page/home/page/chat/widget/disclaimer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/page/home/page/chat/widget/disclaimer.dart b/lib/ui/page/home/page/chat/widget/disclaimer.dart index fccd95298f9..63070605b8c 100644 --- a/lib/ui/page/home/page/chat/widget/disclaimer.dart +++ b/lib/ui/page/home/page/chat/widget/disclaimer.dart @@ -80,7 +80,7 @@ class DisclaimerWidget extends StatelessWidget { child: WidgetButton( onPressed: onPressed, child: Text( - action ?? 'btn_proceed'.l10n, + action ?? 'btn_ok'.l10n, style: style.systemMessageStyle .copyWith(color: style.colors.primary), ), From 6dc227cc649673010709bcdce8d46f29a5cdf929 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 27 May 2024 10:19:49 +0300 Subject: [PATCH 85/88] Changes --- lib/ui/page/home/page/chat/view.dart | 4 +- .../home/page/chat/widget/disclaimer.dart | 92 +++++++++---------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 84d81085633..20271b8de32 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -731,9 +731,7 @@ class ChatView extends StatelessWidget { } return AnimatedAlign( - duration: const Duration( - milliseconds: 300, - ), + duration: const Duration(milliseconds: 300), curve: Curves.ease, alignment: c.infoAlignment.value, child: AnimatedSizeAndFade(child: child), diff --git a/lib/ui/page/home/page/chat/widget/disclaimer.dart b/lib/ui/page/home/page/chat/widget/disclaimer.dart index 63070605b8c..3824dea8568 100644 --- a/lib/ui/page/home/page/chat/widget/disclaimer.dart +++ b/lib/ui/page/home/page/chat/widget/disclaimer.dart @@ -39,57 +39,51 @@ class DisclaimerWidget extends StatelessWidget { CustomBoxShadow(blurRadius: 8, color: Color(0x22000000)), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 250), - padding: const EdgeInsets.fromLTRB( - 18, - 18, - 18, - 18, - ), - decoration: BoxDecoration( - border: border, - borderRadius: style.cardRadius, - color: Colors.white, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (header != null) - Center( - child: MarkdownWidget( - header!, - style: style.systemMessageStyle, - ), - ), - if (header != null && description != null) - const SizedBox(height: 8), - if (description != null) - MarkdownWidget( - description!, - style: style.systemMessageStyle, - ), - if (span != null) - Text.rich(span!, style: style.systemMessageStyle), - const SizedBox(height: 8), - Center( - child: WidgetButton( - onPressed: onPressed, - child: Text( - action ?? 'btn_ok'.l10n, - style: style.systemMessageStyle - .copyWith(color: style.colors.primary), - ), - ), + child: Container( + padding: const EdgeInsets.fromLTRB( + 18, + 18, + 18, + 18, + ), + decoration: BoxDecoration( + border: border, + borderRadius: style.cardRadius, + color: Colors.white, + ), + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (header != null) + Center( + child: MarkdownWidget( + header!, + style: style.systemMessageStyle, ), - ], + ), + if (header != null && description != null) + const SizedBox(height: 8), + if (description != null) + MarkdownWidget( + description!, + style: style.systemMessageStyle, + ), + if (span != null) Text.rich(span!, style: style.systemMessageStyle), + const SizedBox(height: 8), + Center( + child: WidgetButton( + onPressed: onPressed, + child: Text( + action ?? 'btn_ok'.l10n, + style: style.systemMessageStyle + .copyWith(color: style.colors.primary), + ), + ), ), - ), - ], + ], + ), ), ); } From becbac3612bd5eb4d1c561144ce24eb55bc6749a Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 27 May 2024 17:41:58 +0300 Subject: [PATCH 86/88] Improve `DisclaimerWidget` and behaviour --- lib/domain/model/chat_item.dart | 21 ++- lib/ui/page/home/page/chat/controller.dart | 132 +++++++++++++----- .../page/home/page/chat/message_field/desc.js | 51 +++++-- lib/ui/page/home/page/chat/view.dart | 2 +- .../page/home/page/chat/widget/chat_item.dart | 6 +- .../home/page/chat/widget/disclaimer.dart | 83 ++++++----- lib/ui/widget/markdown.dart | 17 ++- 7 files changed, 229 insertions(+), 83 deletions(-) diff --git a/lib/domain/model/chat_item.dart b/lib/domain/model/chat_item.dart index cdcbd7940c1..5ca18f137c2 100644 --- a/lib/domain/model/chat_item.dart +++ b/lib/domain/model/chat_item.dart @@ -90,7 +90,8 @@ abstract class ChatItem { } final msg = this as ChatMessage; - return msg.text?.val.startsWith('/') == true; + return (msg.text == null || msg.text?.val.trim().isEmpty == true) && + msg.commands.isNotEmpty; } @override @@ -120,7 +121,21 @@ class ChatMessage extends ChatItem { this.text, this.editedAt, this.attachments = const [], - }); + }) { + if (text != null) { + final StringBuffer result = StringBuffer(); + + for (var e in text!.val.split('\n')) { + if (e.startsWith('/')) { + commands.add(e); + } else { + result.writeln(e); + } + } + + text = ChatMessageText(result.toString()); + } + } /// Constructs a [ChatMessage] from the provided [json]. factory ChatMessage.fromJson(Map json) => @@ -142,6 +157,8 @@ class ChatMessage extends ChatItem { @HiveField(8) List attachments; + final List commands = []; + /// Indicates whether the [other] message shares the same [text], [repliesTo], /// [author], [chatId] and [attachments] as this [ChatMessage]. bool isEquals(ChatMessage other) { diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index 90181a58537..be125e8299b 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -468,31 +468,85 @@ class ChatController extends GetxController { send.actions.isNotEmpty) { final List actions = send.actions.toList(); + final StringBuffer text = StringBuffer(); + for (var e in actions) { + if (e.command != null) { + text.writeln(e.command!); + } + + final String trimmed = send.field.text.trim(); + if (trimmed.isNotEmpty) { + text.write(trimmed); + } + + print('parsing ${e.command}...'); + if (e.command?.startsWith('/donate') == true) { + final double? sum = + double.tryParse(e.command!.substring('/donate'.length)); + + print('parsing ${e.command}... sum: $sum'); + if (sum != null) { + if (sum > 100) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + text: const ChatBotText( + text: + 'Ваш [Assist] баланс: \$100.0.\n\nК сожалению, Ваш донат не может быть отправлен, на Вашем счету недостаточно денег. Пожалуйста, пополните свой счёт.', + ), + ), + ); + + return; + } + } + } + } + if (send.field.text.trim().isNotEmpty || send.attachments.isNotEmpty || send.replied.isNotEmpty) { _chatService .sendChatMessage( chat?.chat.value.id ?? id, - text: send.field.text.trim().isEmpty - ? null - : ChatMessageText(send.field.text.trim()), + text: text.isEmpty ? null : ChatMessageText(text.toString()), repliesTo: send.replied.map((e) => e.value).toList(), attachments: send.attachments.map((e) => e.value).toList(), ) .then( - (item) { + (item) async { AudioUtils.once( AudioSource.asset('audio/message_sent.mp3'), ); - if (item != null) { - for (var e in actions) { - if (e.command != null) { - postCommand(e.command!, repliesTo: item); + for (var e in actions) { + if (e.command?.startsWith('/donate') == true) { + final double? sum = double.tryParse( + e.command!.substring('/donate'.length)); + + if (sum != null) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + text: ChatBotText( + text: '\$${sum.toStringAsFixed(2)}', + rfw: + "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 0.5, y: 0.5 }, blurRadius: 1.0, color: 0x99998200, }, { offset: { x: -0.3, y: -0.3 }, blurRadius: 1.5, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x66806C00, }, { offset: { x: 0.5, y: 0.5 }, blurRadius: 2.0, color: 0xE4998200, }, { offset: { x: -0.5, y: -0.5 }, blurRadius: 2.0, color: 0xE4FEFEF9, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, ), ), ], ), ), ), );", + ), + ), + repliesTo: [item!], + ); } } } + + // if (item != null) { + // for (var e in actions) { + // if (e.command != null) { + // postCommand(e.command!, repliesTo: item); + // } + // } + // } }, ).onError( (_, __) { @@ -1144,14 +1198,10 @@ class ChatController extends GetxController { id, text: ChatMessageText.bot( localized: { - const Locale('en', 'US'): const ChatBotText( - title: 'Translation', - text: 'Translated ✅', - ), - const Locale('ru', 'RU'): const ChatBotText( - title: 'Перевод', - text: 'Переведено ✅', - ), + const Locale('en', 'US'): + const ChatBotText(title: 'Translation', text: 'Translated ✅'), + const Locale('ru', 'RU'): + const ChatBotText(title: 'Перевод', text: 'Переведено ✅'), }, ), repliesTo: [repliesTo], @@ -1180,7 +1230,7 @@ class ChatController extends GetxController { text: ChatBotText( text: '\$${sum.toStringAsFixed(2)}', rfw: - "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", + "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 0.5, y: 0.5 }, blurRadius: 1.0, color: 0x99998200, }, { offset: { x: -0.3, y: -0.3 }, blurRadius: 1.5, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x66806C00, }, { offset: { x: 0.5, y: 0.5 }, blurRadius: 2.0, color: 0xE4998200, }, { offset: { x: -0.5, y: -0.5 }, blurRadius: 2.0, color: 0xE4FEFEF9, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, ), ), ], ), ), ), );", ), ), repliesTo: [repliesTo], @@ -1193,24 +1243,28 @@ class ChatController extends GetxController { double.tryParse(command.substring('/donate'.length)); if (sum != null) { + if (sum > 100) { + await _chatService.sendChatMessage( + id, + text: ChatMessageText.bot( + text: const ChatBotText( + text: + 'Ваш [Assist] баланс: \$100.0.\n\nК сожалению, Ваш донат не может быть отправлен, на Вашем счету недостаточно денег. Пожалуйста, пополните свой счёт.', + ), + ), + ); + + return; + } + await _chatService.sendChatMessage( id, text: ChatMessageText.bot( text: ChatBotText( text: '\$${sum.toStringAsFixed(2)}', rfw: - "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', ), ), ], ), ), ), );", + "import core.widgets; widget root = Container( height: 104.0, constraints: { minWidth: 300.0 }, padding: [4.0, 4.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: 0.0, y: -1.0 }, end: { x: 0.0, y: 1.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: Container( margin: [2.0, 2.0], decoration: { type: 'box', borderRadius: [{ x: 8.0, y: 8.0 }], gradient: { type: 'linear', begin: { x: -1.0, y: 0.0 }, end: { x: 1.0, y: 0.0 }, colors: [0xFFF9C924, 0xFFE4AF18, 0xFFFFF98C, 0xFFFFD440], stops: [0.0, 0.32, 0.68, 1.0], }, }, child: DefaultTextStyle( style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 0.5, y: 0.5 }, blurRadius: 1.0, color: 0x99998200, }, { offset: { x: -0.3, y: -0.3 }, blurRadius: 1.5, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x66806C00, }, { offset: { x: 0.5, y: 0.5 }, blurRadius: 2.0, color: 0xE4998200, }, { offset: { x: -0.5, y: -0.5 }, blurRadius: 2.0, color: 0xE4FEFEF9, }, { offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, child: Stack( children: [ Align( alignment: { x: -1.0, y: -1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: [data.name], style: { fontSize: 17.0 }, textDirection: 'ltr', ), ), ), Align( alignment: { x: 1.0, y: 1.0 }, child: Padding( padding: [4.0, 4.0], child: Text( text: 'DONATION', style: { fontSize: 13.0 }, textDirection: 'ltr', ), ), ), Center( child: Text( text: [data.description], textDirection: 'ltr', style: { color: 0xFFF3CD01, fontSize: 32.0, shadows: [ { offset: { x: 1.0, y: 1.0 }, blurRadius: 3.0, color: 0xE4AC9200, }, { offset: { x: -1.0, y: -1.0 }, blurRadius: 2.0, color: 0xE4FFFF00, }, { offset: { x: 1.0, y: -1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, { offset: { x: -1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, ], }, ), ), ], ), ), ), );", ), - // localized: { - // const Locale('en', 'US'): ChatBotText( - // title: 'Donate', - // text: 'Donated \$${sum.toStringAsFixed(2)}', - // ), - // const Locale('ru', 'RU'): ChatBotText( - // title: 'Донат', - // text: 'Отправлен донат \$${sum.toStringAsFixed(2)}', - // ), - // }, ), ); } @@ -2195,7 +2249,7 @@ class ChatController extends GetxController { final Rx botInfo = Rx(null); final RxBool showInfo = RxBool(false); - final Rx infoAlignment = Rx(Alignment.topCenter); + final Rx infoAlignment = Rx(Alignment.topRight); void _addBotInfo(RxUser e) { Log.info('_addBotInfo($e)'); @@ -2220,6 +2274,11 @@ class ChatController extends GetxController { final bar = decoded?[L10n.chosen.value!.toString()]?['bar'] ?? decoded?['bar']; + RxUser? recipient = user; + recipient ??= + chat?.members.values.firstWhereOrNull((e) => e.user.id != me)?.user; + recipient ??= chat?.members.values.firstOrNull?.user; + botInfo.value = BotInfoElement( text, at: PreciseDateTime.now(), @@ -2232,7 +2291,10 @@ class ChatController extends GetxController { : BotBar.fromJson( bar, args: { - if (user != null) 'name': user?.title, + if (recipient != null) ...{ + 'id': recipient.id.val, + 'name': recipient.title, + }, }, ), ); @@ -2254,8 +2316,10 @@ class ChatController extends GetxController { } if (args?.containsKey('number') == true) { - return str.replaceAll('\$input', - (args!['number']! as double).toStringAsFixed(2)); + return str.replaceAll( + '\$input', + (args!['number']! as double).toStringAsFixed(2), + ); } return str; @@ -2276,7 +2340,9 @@ class ChatController extends GetxController { _myUserService.myUser.value?.num.toString(), 'description': withArgs(a.action?.description), }, - command: a.action?.command, + command: a.action?.command != null + ? withArgs(a.action?.command!) + : null, ), ); break; diff --git a/lib/ui/page/home/page/chat/message_field/desc.js b/lib/ui/page/home/page/chat/message_field/desc.js index 5525e06c37f..03299da50ca 100644 --- a/lib/ui/page/home/page/chat/message_field/desc.js +++ b/lib/ui/page/home/page/chat/message_field/desc.js @@ -33,23 +33,33 @@ widget root = Container( color: 0xFFF3CD01, fontSize: 32.0, shadows: [ + { + offset: { x: 0.5, y: 0.5 }, + blurRadius: 1.0, + color: 0x99998200, + }, + { + offset: { x: -0.3, y: -0.3 }, + blurRadius: 1.5, + color: 0xE4FFFF00, + }, { offset: { x: 1.0, y: 1.0 }, - blurRadius: 3.0, - color: 0xE4AC9200, + blurRadius: 2.0, + color: 0x66806C00, }, { - offset: { x: -1.0, y: -1.0 }, + offset: { x: 0.5, y: 0.5 }, blurRadius: 2.0, - color: 0xE4FFFF00, + color: 0xE4998200, }, { - offset: { x: 1.0, y: -1.0 }, + offset: { x: -0.5, y: -0.5 }, blurRadius: 2.0, - color: 0x33AC9200, + color: 0xE4FEFEF9, }, { - offset: { x: -1.0, y: 1.0 }, + offset: { x: 1.0, y: 1.0 }, blurRadius: 2.0, color: 0x33AC9200, }, @@ -83,7 +93,32 @@ widget root = Container( child: Text( text: [data.description], textDirection: 'ltr', - + style: { + color: 0xFFF3CD01, + fontSize: 32.0, + shadows: [ + { + offset: { x: 1.0, y: 1.0 }, + blurRadius: 3.0, + color: 0xE4AC9200, + }, + { + offset: { x: -1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0xE4FFFF00, + }, + { + offset: { x: 1.0, y: -1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + { + offset: { x: -1.0, y: 1.0 }, + blurRadius: 2.0, + color: 0x33AC9200, + }, + ], + }, ), ), ], diff --git a/lib/ui/page/home/page/chat/view.dart b/lib/ui/page/home/page/chat/view.dart index 20271b8de32..d33093fc572 100644 --- a/lib/ui/page/home/page/chat/view.dart +++ b/lib/ui/page/home/page/chat/view.dart @@ -726,7 +726,7 @@ class ChatView extends StatelessWidget { } else { child = const SizedBox( key: Key('123'), - width: double.infinity, + width: 415, ); } diff --git a/lib/ui/page/home/page/chat/widget/chat_item.dart b/lib/ui/page/home/page/chat/widget/chat_item.dart index 5506d6ac371..79c49a1d1cd 100644 --- a/lib/ui/page/home/page/chat/widget/chat_item.dart +++ b/lib/ui/page/home/page/chat/widget/chat_item.dart @@ -850,7 +850,7 @@ class _ChatItemWidgetState extends State { }, ), Text( - '/${command.text}', + '${command.text}', style: style.fonts.smallest.regular.onBackground, ), ], @@ -2047,14 +2047,14 @@ class _ChatItemWidgetState extends State { attachments = msg.attachments.length; text = msg.text; - if (text?.val.startsWith('/') ?? false) { + if (msg.isCommand) { _command = ChatCommand( msg.id, msg.chatId, msg.author, msg.at, repliesTo: msg.repliesTo.firstOrNull, - text: ChatMessageText(text!.val.substring(1)), + text: ChatMessageText(msg.commands.join('\n')), ); } else if (msg.repliesTo.isEmpty && (text?.val.startsWith('[@bot]') ?? false)) { diff --git a/lib/ui/page/home/page/chat/widget/disclaimer.dart b/lib/ui/page/home/page/chat/widget/disclaimer.dart index 3824dea8568..2431519e2e7 100644 --- a/lib/ui/page/home/page/chat/widget/disclaimer.dart +++ b/lib/ui/page/home/page/chat/widget/disclaimer.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:messenger/l10n/l10n.dart'; -import 'package:messenger/themes.dart'; -import 'package:messenger/ui/widget/widget_button.dart'; -import '../../../../../widget/markdown.dart'; +import '/themes.dart'; +import '/ui/widget/markdown.dart'; +import '/ui/widget/svg/svg.dart'; +import '/ui/widget/widget_button.dart'; +import '/util/platform_utils.dart'; class DisclaimerWidget extends StatelessWidget { const DisclaimerWidget({ @@ -40,48 +41,60 @@ class DisclaimerWidget extends StatelessWidget { ], ), child: Container( - padding: const EdgeInsets.fromLTRB( - 18, - 18, - 18, - 18, - ), decoration: BoxDecoration( border: border, borderRadius: style.cardRadius, color: Colors.white, ), - width: 300, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + width: context.isNarrow ? double.infinity : 400, + child: Stack( children: [ - if (header != null) - Center( - child: MarkdownWidget( - header!, - style: style.systemMessageStyle, - ), - ), - if (header != null && description != null) - const SizedBox(height: 8), - if (description != null) - MarkdownWidget( - description!, - style: style.systemMessageStyle, + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (header != null) + Center( + child: MarkdownWidget( + header!, + style: style.fonts.big.regular.onBackground, + ), + ), + if (header != null && description != null) + Container( + color: style.colors.secondaryHighlightDarkest, + height: 1, + width: double.infinity, + margin: const EdgeInsets.fromLTRB(10, 16, 10, 16), + ), + if (description != null) + Center( + child: MarkdownWidget( + description!, + style: style.fonts.normal.regular.secondary, + ), + ), + if (span != null) + Text.rich( + span!, + style: style.fonts.normal.regular.secondary, + ), + ], ), - if (span != null) Text.rich(span!, style: style.systemMessageStyle), - const SizedBox(height: 8), - Center( + ), + Positioned( + top: 0, + right: 0, child: WidgetButton( onPressed: onPressed, - child: Text( - action ?? 'btn_ok'.l10n, - style: style.systemMessageStyle - .copyWith(color: style.colors.primary), + child: const Padding( + padding: EdgeInsets.fromLTRB(12, 14, 14, 8), + child: SvgIcon(SvgIcons.closeSmallPrimary), ), ), - ), + ) ], ), ), diff --git a/lib/ui/widget/markdown.dart b/lib/ui/widget/markdown.dart index e4c60643361..88e3c1fb924 100644 --- a/lib/ui/widget/markdown.dart +++ b/lib/ui/widget/markdown.dart @@ -17,6 +17,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:messenger/config.dart'; +import 'package:messenger/routes.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '/themes.dart'; @@ -36,7 +38,20 @@ class MarkdownWidget extends StatelessWidget { return MarkdownBody( data: body, - onTapLink: (_, href, __) async => await launchUrlString(href!), + onTapLink: (_, href, __) async { + href = href?.startsWith('http') == false ? 'https://$href' : href; + + final List origins = [Config.origin, Config.link]; + + for (var e in origins) { + if (href?.startsWith(e) == true) { + router.push(href!.replaceFirst(e, '')); + return; + } + } + + await launchUrlString(href!); + }, styleSheet: MarkdownStyleSheet( h2Padding: const EdgeInsets.fromLTRB(0, 24, 0, 4), From 20b36e859b0506edcca7976f4512afeccfcfcf62 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Fri, 31 May 2024 18:35:31 +0300 Subject: [PATCH 87/88] Fix inability to send messages --- lib/ui/page/home/page/chat/controller.dart | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/lib/ui/page/home/page/chat/controller.dart b/lib/ui/page/home/page/chat/controller.dart index be125e8299b..bf5811f1d0f 100644 --- a/lib/ui/page/home/page/chat/controller.dart +++ b/lib/ui/page/home/page/chat/controller.dart @@ -473,18 +473,18 @@ class ChatController extends GetxController { if (e.command != null) { text.writeln(e.command!); } + } - final String trimmed = send.field.text.trim(); - if (trimmed.isNotEmpty) { - text.write(trimmed); - } + final String trimmed = send.field.text.trim(); + if (trimmed.isNotEmpty) { + text.write(trimmed); + } - print('parsing ${e.command}...'); + for (var e in actions) { if (e.command?.startsWith('/donate') == true) { final double? sum = double.tryParse(e.command!.substring('/donate'.length)); - print('parsing ${e.command}... sum: $sum'); if (sum != null) { if (sum > 100) { await _chatService.sendChatMessage( @@ -503,7 +503,7 @@ class ChatController extends GetxController { } } - if (send.field.text.trim().isNotEmpty || + if (text.isNotEmpty || send.attachments.isNotEmpty || send.replied.isNotEmpty) { _chatService @@ -539,14 +539,6 @@ class ChatController extends GetxController { } } } - - // if (item != null) { - // for (var e in actions) { - // if (e.command != null) { - // postCommand(e.command!, repliesTo: item); - // } - // } - // } }, ).onError( (_, __) { From 18073f386bb1a66e001ab20d8973d3841c8909d0 Mon Sep 17 00:00:00 2001 From: SleepySquash Date: Mon, 3 Jun 2024 14:32:58 +0300 Subject: [PATCH 88/88] Upgrade `medea_jason` --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 39b25e8b186..4cde356950d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1195,10 +1195,10 @@ packages: dependency: "direct main" description: name: medea_jason - sha256: "1b5cbc4b4d26ed21d879fd430dca4fab702b94b914630cd879257cb56e7a885f" + sha256: ac7f9f28c7544c390f70adae7d59dc12e00a397d34811d61d47478023d2e55e1 url: "https://pub.dev" source: hosted - version: "0.5.0-dev+rev.251b8abfcd67b4d60821ed1f7c15484c29b640a9" + version: "0.5.0-dev+rev.d615057ffe2c7031112c3047c1a11b4edc6c2454" media_kit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 079d7d69373..3248facabe9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,7 +68,7 @@ dependencies: log_me: ^0.1.2 material_floating_search_bar: ^0.3.7 medea_flutter_webrtc: 0.10.0-dev+rev.56483212e465ddaaee4593a4e4b7cc828e3faeb3 - medea_jason: 0.5.0-dev+rev.251b8abfcd67b4d60821ed1f7c15484c29b640a9 + medea_jason: 0.5.0-dev+rev.d615057ffe2c7031112c3047c1a11b4edc6c2454 media_kit: ^1.1.5 media_kit_libs_android_video: ^1.3.2 media_kit_libs_ios_video: ^1.1.3