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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