From b7c4832ee2973fdd72468c2cb48cd7369fc28c58 Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 20 May 2026 03:24:00 +0200 Subject: [PATCH] improving onboarding flow and start with security profiles --- CHANGELOG.md | 1 + lib/src/callbacks/logging.callbacks.dart | 4 +- lib/src/constants/routes.keys.dart | 2 + .../generated/app_localizations.dart | 104 +++++++++- .../generated/app_localizations_de.dart | 61 +++++- .../generated/app_localizations_en.dart | 62 +++++- lib/src/localization/translations | 2 +- lib/src/model/json/userdata.model.dart | 14 +- lib/src/model/json/userdata.model.g.dart | 24 ++- lib/src/providers/routing.provider.dart | 5 + lib/src/services/api.service.dart | 48 +---- lib/src/services/profile.service.dart | 7 + lib/src/utils/log.dart | 11 +- .../verification_badge_info.comp.dart | 22 +- .../camera_scanned_overlay.dart | 14 +- .../main_camera_controller.dart | 28 ++- .../views/camera/camera_qr_scanner.view.dart | 1 + .../views/camera/camera_send_to.view.dart | 1 + .../visual/views/chats/chat_list.view.dart | 2 +- lib/src/visual/views/home.view.dart | 1 + .../views/onboarding/register.view.dart | 3 +- .../visual/views/onboarding/setup.view.dart | 71 +++++-- .../setup/components/next_button.comp.dart | 77 +++---- .../setup/components/profile_card.comp.dart | 192 ++++++++++++++++++ .../setup/profile_selection.setup.dart | 111 ++++++++++ .../setup/security_profile.setup.dart | 79 +++++++ .../visual/views/settings/privacy.view.dart | 13 ++ .../privacy/profile_selection.view.dart | 79 +++++++ .../components/user_discovery_setup.comp.dart | 12 +- rust/src/log.rs | 8 +- test/features/flame_counter_test.dart | 3 +- test/mocks/platform_channels.dart | 76 ++++--- test/mocks/test_client.dart | 3 +- test/mocks/user_environment.dart | 3 +- test/services/backup_service_test.dart | 3 +- test/services/group_service_test.dart | 3 +- 36 files changed, 966 insertions(+), 184 deletions(-) create mode 100644 lib/src/services/profile.service.dart create mode 100644 lib/src/visual/views/onboarding/setup/components/profile_card.comp.dart create mode 100644 lib/src/visual/views/onboarding/setup/profile_selection.setup.dart create mode 100644 lib/src/visual/views/onboarding/setup/security_profile.setup.dart create mode 100644 lib/src/visual/views/settings/privacy/profile_selection.view.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 85dca3ef..a5d42e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.2.17 - New: Adds an "Ask a Friend" button to new contact suggestions. +- New: Adds security profiles. - Improved: Onboarding flow for new users. - Improved: The blue verification checkmark now displays the total number of verifications. - Fix: Issue with receiving messages when user closed app while decrypting diff --git a/lib/src/callbacks/logging.callbacks.dart b/lib/src/callbacks/logging.callbacks.dart index bba3d194..3426c5df 100644 --- a/lib/src/callbacks/logging.callbacks.dart +++ b/lib/src/callbacks/logging.callbacks.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:twonly/src/utils/log.dart'; @@ -15,7 +16,8 @@ class LoggingCallbacks { Log.info(log.split('INFO ')[1]); } else if (log.contains('DEBUG ')) { Log.info(log.split('DEBUG ')[1]); - } else if (kDebugMode) { + } else if (kDebugMode && !Platform.environment.containsKey('FLUTTER_TEST')) { + // ignore: avoid_print print(log); } }, diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 2835d4e1..4334b37c 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -35,6 +35,8 @@ class Routes { '/settings/privacy/block_users'; static const String settingsPrivacyUserDiscovery = '/settings/privacy/user_discovery'; + static const String settingsPrivacyProfileSelection = + '/settings/privacy/profile_selection'; static const String settingsNotification = '/settings/notification'; static const String settingsStorage = '/settings/storage_data'; static const String settingsStorageManage = '/settings/storage_data/manage'; diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 5dfb6404..c0f1e110 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -626,6 +626,54 @@ abstract class AppLocalizations { /// **'{len} contact(s)'** String settingsPrivacyBlockUsersCount(Object len); + /// No description provided for @settingsPrivacyProfileSelectionTitle. + /// + /// In en, this message translates to: + /// **'Security Profile'** + String get settingsPrivacyProfileSelectionTitle; + + /// No description provided for @settingsPrivacyProfileSelectionDesc. + /// + /// In en, this message translates to: + /// **'Choose your setup path and security configuration'** + String get settingsPrivacyProfileSelectionDesc; + + /// No description provided for @securityProfileTitle. + /// + /// In en, this message translates to: + /// **'Security Profile'** + String get securityProfileTitle; + + /// No description provided for @securityProfileSubtitle. + /// + /// In en, this message translates to: + /// **'Choose the level of protection that fits your daily use. This can be changed at any time in your settings.'** + String get securityProfileSubtitle; + + /// No description provided for @securityProfileNormalTitle. + /// + /// In en, this message translates to: + /// **'Normal Protection'** + String get securityProfileNormalTitle; + + /// No description provided for @securityProfileNormalDesc. + /// + /// In en, this message translates to: + /// **'Good balance between a convenient mode without bothering you too much.'** + String get securityProfileNormalDesc; + + /// No description provided for @securityProfileStrictTitle. + /// + /// In en, this message translates to: + /// **'Strict Protection'** + String get securityProfileStrictTitle; + + /// No description provided for @securityProfileStrictDesc. + /// + /// In en, this message translates to: + /// **'Maximum anti-phishing protection but may be inconvenient.'** + String get securityProfileStrictDesc; + /// No description provided for @settingsNotification. /// /// In en, this message translates to: @@ -2441,7 +2489,7 @@ abstract class AppLocalizations { /// No description provided for @userDiscoverySettingsManualApproval. /// /// In en, this message translates to: - /// **'Ask before sharing'** + /// **'Ask every time before sharing'** String get userDiscoverySettingsManualApproval; /// No description provided for @userDiscoverySettingsManualApprovalDesc. @@ -3355,6 +3403,60 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Show username'** String get showUsername; + + /// No description provided for @onboardingProfileSelectionTitle. + /// + /// In en, this message translates to: + /// **'Choose your setup path'** + String get onboardingProfileSelectionTitle; + + /// No description provided for @onboardingProfileSelectionSubtitle. + /// + /// In en, this message translates to: + /// **'Choose how you want to configure your security and privacy settings.'** + String get onboardingProfileSelectionSubtitle; + + /// No description provided for @onboardingProfileSelectionDefaultTitle. + /// + /// In en, this message translates to: + /// **'Default'** + String get onboardingProfileSelectionDefaultTitle; + + /// No description provided for @onboardingProfileSelectionDefaultDesc. + /// + /// In en, this message translates to: + /// **'Instantly applies recommended settings so you can start using the app.'** + String get onboardingProfileSelectionDefaultDesc; + + /// No description provided for @onboardingProfileSelectionDefaultBadge. + /// + /// In en, this message translates to: + /// **'Fast Setup'** + String get onboardingProfileSelectionDefaultBadge; + + /// No description provided for @onboardingProfileSelectionCustomizeTitle. + /// + /// In en, this message translates to: + /// **'Customize'** + String get onboardingProfileSelectionCustomizeTitle; + + /// No description provided for @onboardingProfileSelectionCustomizeDesc. + /// + /// In en, this message translates to: + /// **'Step-by-step setup so you can decide for yourself.'** + String get onboardingProfileSelectionCustomizeDesc; + + /// No description provided for @onboardingProfileSelectionStrictTitle. + /// + /// In en, this message translates to: + /// **'Enhanced Protection'** + String get onboardingProfileSelectionStrictTitle; + + /// No description provided for @onboardingProfileSelectionStrictDesc. + /// + /// In en, this message translates to: + /// **'Maximum anti-phishing defense. Recommended for *journalists & public figures*.'** + String get onboardingProfileSelectionStrictDesc; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 812e65ec..0b7559fd 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -292,6 +292,34 @@ class AppLocalizationsDe extends AppLocalizations { return '$len Kontakt(e)'; } + @override + String get settingsPrivacyProfileSelectionTitle => 'Sicherheitsprofil'; + + @override + String get settingsPrivacyProfileSelectionDesc => + 'Wähle deinen Setup-Pfad und deine Sicherheitskonfiguration'; + + @override + String get securityProfileTitle => 'Sicherheitsprofil'; + + @override + String get securityProfileSubtitle => + 'Wähle das Schutzniveau, das zu deiner täglichen Nutzung passt. Dies kann jederzeit in den Einstellungen geändert werden.'; + + @override + String get securityProfileNormalTitle => 'Normaler Schutz'; + + @override + String get securityProfileNormalDesc => + 'Gute Balance zwischen Komfort und Sicherheit, ohne dich zu sehr einzuschränken.'; + + @override + String get securityProfileStrictTitle => 'Strikter Schutz'; + + @override + String get securityProfileStrictDesc => + 'Maximaler Schutz vor Phishing, kann aber unkomfortabel sein.'; + @override String get settingsNotification => 'Benachrichtigung'; @@ -1334,7 +1362,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Erfahre, wer dich anfragt'; @override - String get userDiscoverySettingsManualApproval => 'Vor dem Teilen fragen'; + String get userDiscoverySettingsManualApproval => 'Vor jedem Teilen fragen'; @override String get userDiscoverySettingsManualApprovalDesc => @@ -1908,4 +1936,35 @@ class AppLocalizationsDe extends AppLocalizations { @override String get showUsername => 'Benutzernamen anzeigen'; + + @override + String get onboardingProfileSelectionTitle => 'Wähle deinen Setup-Weg'; + + @override + String get onboardingProfileSelectionSubtitle => + 'Wähle aus, wie du deine Sicherheits- und Privatsphäre-Einstellungen konfigurieren möchtest.'; + + @override + String get onboardingProfileSelectionDefaultTitle => 'Standard'; + + @override + String get onboardingProfileSelectionDefaultDesc => + 'Wendet sofort die empfohlenen Einstellungen an, damit du die App direkt nutzen kannst.'; + + @override + String get onboardingProfileSelectionDefaultBadge => 'Schnelles Setup'; + + @override + String get onboardingProfileSelectionCustomizeTitle => 'Anpassen'; + + @override + String get onboardingProfileSelectionCustomizeDesc => + 'Schritt-für-Schritt-Einrichtung, damit du selbst entscheiden kannst.'; + + @override + String get onboardingProfileSelectionStrictTitle => 'Erhöhter Schutz'; + + @override + String get onboardingProfileSelectionStrictDesc => + 'Maximaler Schutz vor Phishing. Empfohlen für *Journalisten & Personen des öffentlichen Lebens*.'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index c9f2aaa7..f635110a 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -288,6 +288,34 @@ class AppLocalizationsEn extends AppLocalizations { return '$len contact(s)'; } + @override + String get settingsPrivacyProfileSelectionTitle => 'Security Profile'; + + @override + String get settingsPrivacyProfileSelectionDesc => + 'Choose your setup path and security configuration'; + + @override + String get securityProfileTitle => 'Security Profile'; + + @override + String get securityProfileSubtitle => + 'Choose the level of protection that fits your daily use. This can be changed at any time in your settings.'; + + @override + String get securityProfileNormalTitle => 'Normal Protection'; + + @override + String get securityProfileNormalDesc => + 'Good balance between a convenient mode without bothering you too much.'; + + @override + String get securityProfileStrictTitle => 'Strict Protection'; + + @override + String get securityProfileStrictDesc => + 'Maximum anti-phishing protection but may be inconvenient.'; + @override String get settingsNotification => 'Notification'; @@ -1325,7 +1353,8 @@ class AppLocalizationsEn extends AppLocalizations { 'Be informed about who is requesting'; @override - String get userDiscoverySettingsManualApproval => 'Ask before sharing'; + String get userDiscoverySettingsManualApproval => + 'Ask every time before sharing'; @override String get userDiscoverySettingsManualApprovalDesc => @@ -1892,4 +1921,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get showUsername => 'Show username'; + + @override + String get onboardingProfileSelectionTitle => 'Choose your setup path'; + + @override + String get onboardingProfileSelectionSubtitle => + 'Choose how you want to configure your security and privacy settings.'; + + @override + String get onboardingProfileSelectionDefaultTitle => 'Default'; + + @override + String get onboardingProfileSelectionDefaultDesc => + 'Instantly applies recommended settings so you can start using the app.'; + + @override + String get onboardingProfileSelectionDefaultBadge => 'Fast Setup'; + + @override + String get onboardingProfileSelectionCustomizeTitle => 'Customize'; + + @override + String get onboardingProfileSelectionCustomizeDesc => + 'Step-by-step setup so you can decide for yourself.'; + + @override + String get onboardingProfileSelectionStrictTitle => 'Enhanced Protection'; + + @override + String get onboardingProfileSelectionStrictDesc => + 'Maximum anti-phishing defense. Recommended for *journalists & public figures*.'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations index f356d455..18aa5e1a 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit f356d455e46d223ca2ea370892d23e54a6fe48c4 +Subproject commit 18aa5e1afc76a20ded04e9d2c4321fec1c91183d diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index b18218b9..e3aa59a6 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:twonly/src/services/profile.service.dart'; part 'userdata.model.g.dart'; @JsonSerializable() @@ -10,9 +11,9 @@ class UserData { required this.displayName, required this.subscriptionPlan, required this.currentSetupPage, + required this.appVersion, }); - factory UserData.fromJson(Map json) => - _$UserDataFromJson(json); + factory UserData.fromJson(Map json) => _$UserDataFromJson(json); final int userId; @@ -35,6 +36,12 @@ class UserData { @JsonKey(defaultValue: 0) int deviceId = 0; + @JsonKey(defaultValue: SetupProfile.standard) + SetupProfile setupProfile = SetupProfile.standard; + + @JsonKey(defaultValue: SecurityProfile.normal) + SecurityProfile securityProfile = SecurityProfile.normal; + // --- SUBSCRIPTION DTA --- @JsonKey(defaultValue: 'Free') @@ -179,8 +186,7 @@ class TwonlySafeBackup { required this.backupId, required this.encryptionKey, }); - factory TwonlySafeBackup.fromJson(Map json) => - _$TwonlySafeBackupFromJson(json); + factory TwonlySafeBackup.fromJson(Map json) => _$TwonlySafeBackupFromJson(json); int lastBackupSize = 0; LastBackupUploadState backupUploadState = LastBackupUploadState.none; diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 21165539..4ae054fd 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -13,13 +13,22 @@ UserData _$UserDataFromJson(Map json) => displayName: json['displayName'] as String, subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Free', currentSetupPage: json['currentSetupPage'] as String?, + appVersion: (json['appVersion'] as num?)?.toInt() ?? 0, ) ..avatarSvg = json['avatarSvg'] as String? ..avatarJson = json['avatarJson'] as String? - ..appVersion = (json['appVersion'] as num?)?.toInt() ?? 0 ..avatarCounter = (json['avatarCounter'] as num?)?.toInt() ?? 0 ..isDeveloper = json['isDeveloper'] as bool? ?? false ..deviceId = (json['deviceId'] as num?)?.toInt() ?? 0 + ..setupProfile = + $enumDecodeNullable(_$SetupProfileEnumMap, json['setupProfile']) ?? + SetupProfile.standard + ..securityProfile = + $enumDecodeNullable( + _$SecurityProfileEnumMap, + json['securityProfile'], + ) ?? + SecurityProfile.normal ..subscriptionPlanIdStore = json['subscriptionPlanIdStore'] as String? ..lastImageSend = json['lastImageSend'] == null ? null @@ -115,6 +124,8 @@ Map _$UserDataToJson(UserData instance) => { 'avatarCounter': instance.avatarCounter, 'isDeveloper': instance.isDeveloper, 'deviceId': instance.deviceId, + 'setupProfile': _$SetupProfileEnumMap[instance.setupProfile]!, + 'securityProfile': _$SecurityProfileEnumMap[instance.securityProfile]!, 'subscriptionPlan': instance.subscriptionPlan, 'subscriptionPlanIdStore': instance.subscriptionPlanIdStore, 'lastImageSend': instance.lastImageSend?.toIso8601String(), @@ -168,6 +179,17 @@ Map _$UserDataToJson(UserData instance) => { 'hasZoomed': instance.hasZoomed, }; +const _$SetupProfileEnumMap = { + SetupProfile.standard: 'standard', + SetupProfile.customized: 'customized', + SetupProfile.maximum: 'maximum', +}; + +const _$SecurityProfileEnumMap = { + SecurityProfile.normal: 'normal', + SecurityProfile.strict: 'strict', +}; + const _$ThemeModeEnumMap = { ThemeMode.system: 'system', ThemeMode.light: 'light', diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 47408885..967887d0 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -39,6 +39,7 @@ import 'package:twonly/src/visual/views/settings/help/help.view.dart'; import 'package:twonly/src/visual/views/settings/notification.view.dart'; import 'package:twonly/src/visual/views/settings/privacy.view.dart'; import 'package:twonly/src/visual/views/settings/privacy/block_users.view.dart'; +import 'package:twonly/src/visual/views/settings/privacy/profile_selection.view.dart'; import 'package:twonly/src/visual/views/settings/privacy/user_discovery.view.dart'; import 'package:twonly/src/visual/views/settings/profile/modify_avatar.view.dart'; import 'package:twonly/src/visual/views/settings/profile/profile.view.dart'; @@ -205,6 +206,10 @@ final routerProvider = GoRouter( path: 'user_discovery', builder: (context, state) => const UserDiscoverySettingsView(), ), + GoRoute( + path: 'profile_selection', + builder: (context, state) => const ProfileSelectionSettingsView(), + ), ], ), GoRoute( diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 767fe3e6..65cc226b 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -21,8 +21,7 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; -import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' - as server; +import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' as server; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart'; @@ -66,15 +65,13 @@ class ApiService { Stream get onPlanUpdated => _planUpdateController.stream; final _connectionStateController = StreamController.broadcast(); - Stream get onConnectionStateUpdated => - _connectionStateController.stream; + Stream get onConnectionStateUpdated => _connectionStateController.stream; final _appOutdatedController = StreamController.broadcast(); Stream get onAppOutdated => _appOutdatedController.stream; final _newDeviceRegisteredController = StreamController.broadcast(); - Stream get onNewDeviceRegistered => - _newDeviceRegisteredController.stream; + Stream get onNewDeviceRegistered => _newDeviceRegisteredController.stream; bool appIsOutdated = false; bool isAuthenticated = false; @@ -83,18 +80,12 @@ class ApiService { Timer? reconnectionTimer; int _reconnectionDelay = 5; - final HashMap> _pendingRequests = - HashMap(); + final HashMap> _pendingRequests = HashMap(); IOWebSocketChannel? _channel; // ignore: cancel_subscriptions StreamSubscription>? _connectivitySubscription; Future _connectTo(String apiUrl) async { - if (kDebugMode) { - print( - 'DEBUG: ApiService._connectTo called with: $apiUrl (appIsOutdated=$appIsOutdated)', - ); - } if (appIsOutdated) return false; try { final channel = IOWebSocketChannel.connect( @@ -113,11 +104,8 @@ class ApiService { _channel!.stream.listen(_onData, onDone: _onDone, onError: _onError); Log.info('websocket connected to $apiUrl'); return true; - } catch (e, s) { + } catch (e) { _channel = null; - if (kDebugMode) { - print('DEBUG: _connectTo caught exception: $e\n$s'); - } return false; } } @@ -164,9 +152,6 @@ class ApiService { } Future onClosed() async { - if (kDebugMode) { - print('API onClosed called'); - } if (_channel == null) return; Log.info('websocket connection closed'); _channel = null; @@ -254,17 +239,11 @@ class ApiService { bool get isConnected => _channel != null && _channel!.closeCode == null; Future _onDone() async { - if (kDebugMode) { - print('API _onDone called'); - } _reconnectionDelay = 3; await onClosed(); } Future _onError(dynamic e) async { - if (kDebugMode) { - print('API _onError called: $e'); - } if (e.toString().contains('Failed host lookup')) { Log.info('WebSocket connection failed: Host not reachable.'); } else { @@ -439,9 +418,7 @@ class ApiService { } if (res.error == ErrorCode.UserIdNotFound && contactId != null) { Log.warn('Contact deleted their account $contactId.'); - final contact = await twonlyDB.contactsDao - .getContactByUserId(contactId) - .getSingleOrNull(); + final contact = await twonlyDB.contactsDao.getContactByUserId(contactId).getSingleOrNull(); if (contact != null) { await twonlyDB.contactsDao.updateContact( contactId, @@ -506,8 +483,7 @@ class ApiService { return true; } if (result.isError) { - if (result.error != ErrorCode.AuthTokenNotValid && - result.error != ErrorCode.ForegroundSessionConnected) { + if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) { Log.error( 'got error while authenticating to the server: ${result.error}', ); @@ -545,8 +521,7 @@ class ApiService { return true; } if (result.isError) { - if (result.error != ErrorCode.AuthTokenNotValid && - result.error != ErrorCode.ForegroundSessionConnected) { + if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) { Log.error( 'got error while authenticating to the server: ${result.error}', ); @@ -578,8 +553,7 @@ class ApiService { return; } - final handshake = Handshake() - ..getAuthChallenge = Handshake_GetAuthChallenge(); + final handshake = Handshake()..getAuthChallenge = Handshake_GetAuthChallenge(); final req = createClientToServerFromHandshake(handshake); final result = await sendRequestSync(req, authenticated: false); @@ -644,9 +618,7 @@ class ApiService { final register = Handshake_Register() ..username = username - ..publicIdentityKey = (await signalStore.getIdentityKeyPair()) - .getPublicKey() - .serialize() + ..publicIdentityKey = (await signalStore.getIdentityKeyPair()).getPublicKey().serialize() ..registrationId = Int64(signalIdentity.registrationId) ..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize() ..signedPrekeySignature = signedPreKey.signature diff --git a/lib/src/services/profile.service.dart b/lib/src/services/profile.service.dart new file mode 100644 index 00000000..501d223e --- /dev/null +++ b/lib/src/services/profile.service.dart @@ -0,0 +1,7 @@ +enum SetupProfile { standard, customized, maximum } + +enum SecurityProfile { normal, strict } + +extension SecurityProfileExtension on SecurityProfile { + bool get showWarningForNonVerifiedContacts => this == SecurityProfile.strict; +} diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index d151d3b3..63a73b36 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -18,10 +18,13 @@ class Log { Logger.root.onRecord.listen((record) async { unawaited(_writeLogToFile(record)); if (!kReleaseMode) { - // ignore: avoid_print - print( - '${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}', - ); + if (!Platform.environment.containsKey('FLUTTER_TEST') || + record.level >= Level.WARNING) { + // ignore: avoid_print + print( + '${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}', + ); + } } }); } diff --git a/lib/src/visual/components/verification_badge_info.comp.dart b/lib/src/visual/components/verification_badge_info.comp.dart index cded90c2..60bfaa07 100644 --- a/lib/src/visual/components/verification_badge_info.comp.dart +++ b/lib/src/visual/components/verification_badge_info.comp.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart'; import 'package:twonly/src/visual/themes/light.dart'; @@ -23,16 +25,18 @@ class VerificationBadgeInfo extends StatelessWidget { description: context.lang.verificationBadgeGreenDesc, boldTextColor: primaryColor, ), - _buildItem( - context, - icon: const SvgIcon( - assetPath: SvgIcons.verifiedGreen, - size: 40, - color: colorVerificationBadgeYellow, + if (userService.currentUser.securityProfile != SecurityProfile.strict || + userService.currentUser.isUserDiscoveryEnabled) + _buildItem( + context, + icon: const SvgIcon( + assetPath: SvgIcons.verifiedGreen, + size: 40, + color: colorVerificationBadgeYellow, + ), + description: context.lang.verificationBadgeYellowDesc, + boldTextColor: colorVerificationBadgeYellow, ), - description: context.lang.verificationBadgeYellowDesc, - boldTextColor: colorVerificationBadgeYellow, - ), _buildItem( context, icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40), diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart index 9476b222..8e003fcc 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/camera_scanned_overlay.dart @@ -33,8 +33,7 @@ class CameraScannedOverlay extends StatelessWidget { ...mainController.contactsVerified.values.map( (c) => _buildVerifiedContactTile(context, c), ), - if (mainController.scannedUrl != null) - _buildScannedUrlTile(context, mainController.scannedUrl!), + if (mainController.scannedUrl != null) _buildScannedUrlTile(context, mainController.scannedUrl!), ], ), ), @@ -46,15 +45,14 @@ class CameraScannedOverlay extends StatelessWidget { return GestureDetector( onTap: () async { c.isLoading = true; - mainController.setState(); + mainController.setState?.call(); showSnackbar( context, context.lang.requestedUserToastText(c.profile.username), level: SnackbarLevel.success, ); - if (await addNewContactFromPublicProfile(c.profile) && - context.mounted) { + if (await addNewContactFromPublicProfile(c.profile) && context.mounted) { // showSnackbar( // context, // context.lang.requestedUserToastText(c.profile.username), @@ -121,13 +119,11 @@ class CameraScannedOverlay extends StatelessWidget { child: SizedBox( width: 30, child: Lottie.asset( - c.verificationOk - ? 'assets/animations/success.lottie' - : 'assets/animations/failed.lottie', + c.verificationOk ? 'assets/animations/success.lottie' : 'assets/animations/failed.lottie', repeat: false, onLoaded: (p0) { Future.delayed(const Duration(seconds: 4), () { - mainController.setState(); + mainController.setState?.call(); }); }, ), diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index f713585e..3cf2c903 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -40,7 +40,7 @@ class ScannedNewProfile { } class MainCameraController { - late void Function() setState; + void Function()? setState; CameraController? cameraController; ScreenshotController screenshotController = ScreenshotController(); SelectedCameraDetails selectedCameraDetails = SelectedCameraDetails(); @@ -61,12 +61,12 @@ class MainCameraController { void setSharedLinkForPreview(Uri? url) { sharedLinkForPreview = url; - setState(); + setState?.call(); } void onImageSend() { scannedUrl = ''; - setState(); + setState?.call(); } final BarcodeScanner _barcodeScanner = BarcodeScanner(); @@ -115,6 +115,7 @@ class MainCameraController { ); initCameraStarted = false; selectedCameraDetails = SelectedCameraDetails(); + setState?.call(); } Future selectCamera(int sCameraId, bool init) async { @@ -153,8 +154,11 @@ class MainCameraController { try { _initializeFuture = cameraController?.initialize(); await _initializeFuture; + if (cameraController == null) return; await cameraController?.startImageStream(_processCameraImage); + if (cameraController == null) return; await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); + if (cameraController == null) return; if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { await cameraController?.setVideoStabilizationMode( VideoStabilizationMode.level1, @@ -172,10 +176,13 @@ class MainCameraController { } catch (e) { Log.info(e); } + if (cameraController == null) return; selectedCameraDetails.scaleFactor = 1; await cameraController?.setZoomLevel(1); + if (cameraController == null) return; await cameraController?.setDescription(AppEnvironment.cameras[cameraId]); + if (cameraController == null) return; try { if (!isVideoRecording) { await cameraController?.startImageStream(_processCameraImage); @@ -186,12 +193,15 @@ class MainCameraController { } try { + if (cameraController == null) return; await cameraController?.lockCaptureOrientation( DeviceOrientation.portraitUp, ); + if (cameraController == null) return; await cameraController?.setFlashMode( selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, ); + if (cameraController == null) return; selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1; selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1; selectedCameraDetails @@ -204,7 +214,7 @@ class MainCameraController { isSelectingFaceFilters = false; setFilter(FaceFilterType.none); zoomButtonKey = GlobalKey(); - setState(); + setState?.call(); } catch (e) { Log.error(e); cameraController = null; @@ -226,7 +236,7 @@ class MainCameraController { final dx = (localPosition.dx / box.size.width).clamp(0.0, 1.0); final dy = (localPosition.dy / box.size.height).clamp(0.0, 1.0); - setState(); + setState?.call(); await HapticFeedback.lightImpact(); try { @@ -244,7 +254,7 @@ class MainCameraController { await Future.delayed(const Duration(milliseconds: 500)); focusPointOffset = null; - setState(); + setState?.call(); } void setFilter(FaceFilterType type) { @@ -254,7 +264,7 @@ class MainCameraController { facePaint = null; _isBusyFaces = false; } - setState(); + setState?.call(); } FaceFilterPainter? faceFilterPainter; @@ -419,7 +429,7 @@ class MainCameraController { } } _isBusy = false; - setState(); + setState?.call(); } Future _processFaces(InputImage inputImage) async { @@ -465,6 +475,6 @@ class MainCameraController { } } _isBusyFaces = false; - setState(); + setState?.call(); } } diff --git a/lib/src/visual/views/camera/camera_qr_scanner.view.dart b/lib/src/visual/views/camera/camera_qr_scanner.view.dart index d7d01b98..87614e4b 100644 --- a/lib/src/visual/views/camera/camera_qr_scanner.view.dart +++ b/lib/src/visual/views/camera/camera_qr_scanner.view.dart @@ -24,6 +24,7 @@ class QrCodeScannerViewState extends State { @override void dispose() { + _mainCameraController.setState = null; _mainCameraController.closeCamera(); super.dispose(); } diff --git a/lib/src/visual/views/camera/camera_send_to.view.dart b/lib/src/visual/views/camera/camera_send_to.view.dart index e1370f74..5e063540 100644 --- a/lib/src/visual/views/camera/camera_send_to.view.dart +++ b/lib/src/visual/views/camera/camera_send_to.view.dart @@ -26,6 +26,7 @@ class CameraSendToViewState extends State { @override void dispose() { + _mainCameraController.setState = null; _mainCameraController.closeCamera(); super.dispose(); } diff --git a/lib/src/visual/views/chats/chat_list.view.dart b/lib/src/visual/views/chats/chat_list.view.dart index 4e1979e1..251b949c 100644 --- a/lib/src/visual/views/chats/chat_list.view.dart +++ b/lib/src/visual/views/chats/chat_list.view.dart @@ -38,7 +38,7 @@ class _ChatListViewState extends State { List _groupsPinned = []; List _groupsArchived = []; - bool _hasContacts = true; + bool _hasContacts = false; bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; GlobalKey searchForOtherUsers = GlobalKey(); diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index 67a02382..b44b87f6 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -169,6 +169,7 @@ class HomeViewState extends State { _homeViewPageIndexSub?.cancel(); _selectNotificationSub?.cancel(); _disableCameraTimer?.cancel(); + _mainCameraController.setState = null; _mainCameraController.closeCamera(); _intentStreamSub?.cancel(); _deepLinkSub?.cancel(); diff --git a/lib/src/visual/views/onboarding/register.view.dart b/lib/src/visual/views/onboarding/register.view.dart index 8458cd97..570456c7 100644 --- a/lib/src/visual/views/onboarding/register.view.dart +++ b/lib/src/visual/views/onboarding/register.view.dart @@ -140,7 +140,8 @@ class _RegisterViewState extends State { displayName: username, subscriptionPlan: 'Free', currentSetupPage: SetupPages.profile.name, - )..appVersion = AppState.latestAppVersionId; + appVersion: AppState.latestAppVersionId, + ); await UserService.save(userData); diff --git a/lib/src/visual/views/onboarding/setup.view.dart b/lib/src/visual/views/onboarding/setup.view.dart index ff1ffa4e..80b1760d 100644 --- a/lib/src/visual/views/onboarding/setup.view.dart +++ b/lib/src/visual/views/onboarding/setup.view.dart @@ -2,11 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/views/onboarding/setup/backup.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/let_your_friends_find_you.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/profile.setup.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/profile_selection.setup.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/security_profile.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/share_your_friends.setup.dart'; import 'package:twonly/src/visual/views/onboarding/setup/verification_badge.setup.dart'; import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart'; @@ -14,6 +17,8 @@ import 'package:twonly/src/visual/views/settings/privacy/user_discovery/componen enum SetupPages { profile, backup, + profileSelection, + securityProfile, verificationBadge, shareYourFriends, letYourFriendsFindYou, @@ -27,25 +32,62 @@ extension SetupPagesExtension on SetupPages { ); } - int get pageNumber => index + 1; - int get totalPages => SetupPages.values.length; + static List get activePages { + final setupProfile = userService.currentUser.setupProfile; + switch (setupProfile) { + case SetupProfile.standard: + return [ + SetupPages.profile, + SetupPages.backup, + SetupPages.profileSelection, + ]; + case SetupProfile.maximum: + return [ + SetupPages.profile, + SetupPages.backup, + SetupPages.profileSelection, + SetupPages.verificationBadge, + ]; + case SetupProfile.customized: + return [ + SetupPages.profile, + SetupPages.backup, + SetupPages.profileSelection, + SetupPages.securityProfile, + SetupPages.verificationBadge, + SetupPages.shareYourFriends, + SetupPages.letYourFriendsFindYou, + ]; + } + } + + int get pageNumber { + final idx = activePages.indexOf(this); + return idx != -1 ? idx + 1 : 1; + } + + int get totalPages => activePages.length; int get progressPercentage => ((pageNumber - 1) / totalPages * 100).round(); String get progressText => '$pageNumber / $totalPages'; - bool get isLast => index == SetupPages.values.length - 1; + bool get isLast { + return activePages.isNotEmpty && activePages.last == this; + } SetupPages? next() { - final nextIndex = index + 1; - if (nextIndex < SetupPages.values.length) { - return SetupPages.values[nextIndex]; + final pages = activePages; + final idx = pages.indexOf(this); + if (idx != -1 && idx + 1 < pages.length) { + return pages[idx + 1]; } return null; } SetupPages? previous() { - final prevIndex = index - 1; - if (prevIndex >= 0) { - return SetupPages.values[prevIndex]; + final pages = activePages; + final idx = pages.indexOf(this); + if (idx > 0) { + return pages[idx - 1]; } return null; } @@ -110,9 +152,7 @@ class _SetupViewState extends State { right: index == currentPage.totalPages - 1 ? 0 : 8, ), decoration: BoxDecoration( - color: isFinished - ? context.color.primary - : context.color.surfaceContainer, + color: isFinished ? context.color.primary : context.color.surfaceContainer, borderRadius: BorderRadius.circular(10), ), ), @@ -149,8 +189,7 @@ class _SetupViewState extends State { ), ), ), - if (currentPage.index > 0 && !currentPage.isLast) - const SizedBox(width: 24), + if (currentPage.index > 0 && !currentPage.isLast) const SizedBox(width: 24), if (!currentPage.isLast) TextButton( onPressed: () async { @@ -183,6 +222,10 @@ class _SetupViewState extends State { return const ProfileSetupPage(); case SetupPages.backup: return const BackupSetupPage(); + case SetupPages.profileSelection: + return const ProfileSelectionSetup(); + case SetupPages.securityProfile: + return const SecurityProfileSetup(); case SetupPages.verificationBadge: return const VerificationBadgeSetupPage(); case SetupPages.shareYourFriends: diff --git a/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart b/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart index fbf9eb80..36962bc9 100644 --- a/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart +++ b/lib/src/visual/views/onboarding/setup/components/next_button.comp.dart @@ -18,43 +18,48 @@ class NextButtonComp extends StatelessWidget { @override Widget build(BuildContext context) { - final currentPage = SetupPagesExtension.fromStr( - userService.currentUser.currentSetupPage, - ); - return ElevatedButton( - onPressed: (canSubmit && !isLoading) - ? () async { - if (onPressed != null) { - final error = await onPressed?.call(); - if (error == true) return; - } - await UserService.update((user) { - user.currentSetupPage = currentPage.next()?.name; - }); - } - : null, - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 56), - backgroundColor: context.color.primary, - foregroundColor: context.color.onPrimary, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - child: isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : Text( - currentPage.isLast ? context.lang.finishSetup : context.lang.next, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + final currentPage = SetupPagesExtension.fromStr( + userService.currentUser.currentSetupPage, + ); + return ElevatedButton( + onPressed: (canSubmit && !isLoading) + ? () async { + if (onPressed != null) { + final error = await onPressed?.call(); + if (error == true) return; + } + await UserService.update((user) { + user.currentSetupPage = currentPage.next()?.name; + }); + } + : null, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 56), + backgroundColor: context.color.primary, + foregroundColor: context.color.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), + ), + child: isLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + currentPage.isLast ? context.lang.finishSetup : context.lang.next, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ); + }, ); } } diff --git a/lib/src/visual/views/onboarding/setup/components/profile_card.comp.dart b/lib/src/visual/views/onboarding/setup/components/profile_card.comp.dart new file mode 100644 index 00000000..9f1206cc --- /dev/null +++ b/lib/src/visual/views/onboarding/setup/components/profile_card.comp.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/services/profile.service.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class SafetyProfileCard extends StatelessWidget { + const SafetyProfileCard({ + required this.profile, + required this.isSelected, + required this.onTap, + this.isHovered = false, + this.onHover, + super.key, + }); + + final Object profile; + final bool isSelected; + final VoidCallback onTap; + final bool isHovered; + final ValueChanged? onHover; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final bodyMediumStyle = theme.textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + height: 1.35, + ); + + final String title; + final Widget subtitle; + final IconData icon; + final String? badgeText; + + if (profile is SetupProfile) { + switch (profile as SetupProfile) { + case SetupProfile.standard: + title = context.lang.onboardingProfileSelectionDefaultTitle; + subtitle = Text( + context.lang.onboardingProfileSelectionDefaultDesc, + style: bodyMediumStyle, + ); + icon = Icons.bolt_rounded; + badgeText = context.lang.onboardingProfileSelectionDefaultBadge; + case SetupProfile.customized: + title = context.lang.onboardingProfileSelectionCustomizeTitle; + subtitle = Text( + context.lang.onboardingProfileSelectionCustomizeDesc, + style: bodyMediumStyle, + ); + icon = Icons.tune_rounded; + badgeText = null; + case SetupProfile.maximum: + title = context.lang.onboardingProfileSelectionStrictTitle; + subtitle = RichText( + text: TextSpan( + style: bodyMediumStyle, + children: formattedText( + context, + context.lang.onboardingProfileSelectionStrictDesc, + boldTextColor: context.color.onSurface, + ), + ), + ); + icon = Icons.lock_outline_rounded; + badgeText = null; + } + } else if (profile is SecurityProfile) { + switch (profile as SecurityProfile) { + case SecurityProfile.normal: + title = context.lang.securityProfileNormalTitle; + subtitle = Text( + context.lang.securityProfileNormalDesc, + style: bodyMediumStyle, + ); + icon = Icons.shield_outlined; + badgeText = null; + case SecurityProfile.strict: + title = context.lang.securityProfileStrictTitle; + subtitle = Text( + context.lang.securityProfileStrictDesc, + style: bodyMediumStyle, + ); + icon = Icons.verified_user_outlined; + badgeText = null; + } + } else { + throw ArgumentError('Invalid profile type: $profile'); + } + + return MouseRegion( + onEnter: (_) => onHover?.call(true), + onExit: (_) => onHover?.call(false), + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? context.color.primaryContainer.withValues(alpha: 0.12) + : context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? context.color.primary + : (isHovered + ? context.color.onSurfaceVariant.withValues(alpha: 0.3) + : context.color.outlineVariant.withValues(alpha: 0.4)), + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: context.color.primary.withValues(alpha: 0.06), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : [], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isSelected + ? context.color.primary.withValues(alpha: 0.1) + : context.color.surfaceContainerHigh, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: isSelected + ? context.color.primary + : context.color.onSurfaceVariant, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isSelected + ? context.color.primary + : theme.textTheme.titleMedium?.color, + ), + ), + ), + if (badgeText != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.color.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badgeText, + style: theme.textTheme.labelSmall?.copyWith( + color: context.color.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + subtitle, + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/onboarding/setup/profile_selection.setup.dart b/lib/src/visual/views/onboarding/setup/profile_selection.setup.dart new file mode 100644 index 00000000..abe4f776 --- /dev/null +++ b/lib/src/visual/views/onboarding/setup/profile_selection.setup.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/profile.service.dart'; +import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/components/profile_card.comp.dart'; +import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart'; + +class ProfileSelectionSetup extends StatefulWidget { + const ProfileSelectionSetup({super.key}); + + @override + State createState() => _ProfileSelectionSetupState(); +} + +class _ProfileSelectionSetupState extends State { + SetupProfile? _hoveredProfile; + bool _isLoading = false; + + Future _onProfileTapped(SetupProfile profile) async { + await UserService.update((user) { + user.setupProfile = profile; + if (profile == SetupProfile.standard) { + user.securityProfile = SecurityProfile.normal; + } else if (profile == SetupProfile.maximum) { + user.securityProfile = SecurityProfile.strict; + } + }); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + final user = userService.currentUser; + final selectedProfile = user.setupProfile; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.lang.onboardingProfileSelectionTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + context.lang.onboardingProfileSelectionSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + SafetyProfileCard( + profile: SetupProfile.standard, + isSelected: selectedProfile == SetupProfile.standard, + isHovered: _hoveredProfile == SetupProfile.standard, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SetupProfile.standard : null; + }), + onTap: () => _onProfileTapped(SetupProfile.standard), + ), + const SizedBox(height: 16), + SafetyProfileCard( + profile: SetupProfile.customized, + isSelected: selectedProfile == SetupProfile.customized, + isHovered: _hoveredProfile == SetupProfile.customized, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SetupProfile.customized : null; + }), + onTap: () => _onProfileTapped(SetupProfile.customized), + ), + const SizedBox(height: 16), + SafetyProfileCard( + profile: SetupProfile.maximum, + isSelected: selectedProfile == SetupProfile.maximum, + isHovered: _hoveredProfile == SetupProfile.maximum, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SetupProfile.maximum : null; + }), + onTap: () => _onProfileTapped(SetupProfile.maximum), + ), + const SizedBox(height: 40), + NextButtonComp( + key: ValueKey(selectedProfile), + isLoading: _isLoading, + onPressed: () async { + if (selectedProfile == SetupProfile.standard) { + setState(() { + _isLoading = true; + }); + await UserDiscoverySetupState().initializeOrUpdate(); + setState(() { + _isLoading = false; + }); + } + return false; + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/src/visual/views/onboarding/setup/security_profile.setup.dart b/lib/src/visual/views/onboarding/setup/security_profile.setup.dart new file mode 100644 index 00000000..0bde4049 --- /dev/null +++ b/lib/src/visual/views/onboarding/setup/security_profile.setup.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/profile.service.dart'; +import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/components/profile_card.comp.dart'; + +class SecurityProfileSetup extends StatefulWidget { + const SecurityProfileSetup({super.key}); + + @override + State createState() => _SecurityProfileSetupState(); +} + +class _SecurityProfileSetupState extends State { + SecurityProfile? _hoveredProfile; + + Future _onProfileTapped(SecurityProfile profile) async { + await UserService.update((user) { + user.securityProfile = profile; + }); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + final user = userService.currentUser; + final selectedProfile = user.securityProfile; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.lang.securityProfileTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + context.lang.securityProfileSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + SafetyProfileCard( + profile: SecurityProfile.normal, + isSelected: selectedProfile == SecurityProfile.normal, + isHovered: _hoveredProfile == SecurityProfile.normal, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SecurityProfile.normal : null; + }), + onTap: () => _onProfileTapped(SecurityProfile.normal), + ), + const SizedBox(height: 16), + SafetyProfileCard( + profile: SecurityProfile.strict, + isSelected: selectedProfile == SecurityProfile.strict, + isHovered: _hoveredProfile == SecurityProfile.strict, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SecurityProfile.strict : null; + }), + onTap: () => _onProfileTapped(SecurityProfile.strict), + ), + const SizedBox(height: 40), + const NextButtonComp(), + ], + ); + }, + ); + } +} diff --git a/lib/src/visual/views/settings/privacy.view.dart b/lib/src/visual/views/settings/privacy.view.dart index 2f6561f3..88d43c01 100644 --- a/lib/src/visual/views/settings/privacy.view.dart +++ b/lib/src/visual/views/settings/privacy.view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -78,6 +79,18 @@ class _PrivacyViewState extends State { setState(() {}); }, ), + ListTile( + title: Text(context.lang.settingsPrivacyProfileSelectionTitle), + subtitle: Text( + userService.currentUser.securityProfile == SecurityProfile.strict + ? context.lang.securityProfileStrictTitle + : context.lang.securityProfileNormalTitle, + ), + onTap: () async { + await context.push(Routes.settingsPrivacyProfileSelection); + setState(() {}); + }, + ), const Divider(), ListTile( title: Text(context.lang.settingsTypingIndication), diff --git a/lib/src/visual/views/settings/privacy/profile_selection.view.dart b/lib/src/visual/views/settings/privacy/profile_selection.view.dart new file mode 100644 index 00000000..dfd76c86 --- /dev/null +++ b/lib/src/visual/views/settings/privacy/profile_selection.view.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/profile.service.dart'; +import 'package:twonly/src/services/user.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/onboarding/setup/components/profile_card.comp.dart'; + +class ProfileSelectionSettingsView extends StatefulWidget { + const ProfileSelectionSettingsView({super.key}); + + @override + State createState() => + _ProfileSelectionSettingsViewState(); +} + +class _ProfileSelectionSettingsViewState + extends State { + SecurityProfile? _hoveredProfile; + + Future _onProfileTapped(SecurityProfile profile) async { + await UserService.update((user) { + user.securityProfile = profile; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.lang.securityProfileTitle), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24), + child: StreamBuilder( + stream: userService.onUserUpdated, + builder: (context, snapshot) { + final user = userService.currentUser; + final selectedProfile = user.securityProfile; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.lang.securityProfileSubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + SafetyProfileCard( + profile: SecurityProfile.normal, + isSelected: selectedProfile == SecurityProfile.normal, + isHovered: _hoveredProfile == SecurityProfile.normal, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SecurityProfile.normal : null; + }), + onTap: () => _onProfileTapped(SecurityProfile.normal), + ), + const SizedBox(height: 16), + SafetyProfileCard( + profile: SecurityProfile.strict, + isSelected: selectedProfile == SecurityProfile.strict, + isHovered: _hoveredProfile == SecurityProfile.strict, + onHover: (hovered) => setState(() { + _hoveredProfile = hovered ? SecurityProfile.strict : null; + }), + onTap: () => _onProfileTapped(SecurityProfile.strict), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart index 9fb54a9d..cb474d59 100644 --- a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart +++ b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart @@ -26,7 +26,7 @@ List getExampleUsers(BuildContext context) => [ class UserDiscoverySetupState { UserDiscoverySetupState({ - required this.setState, + this.setState, this.isUserDiscoveryEnabled = true, this.sharePromotion = true, this.isManualApprovalEnabled = false, @@ -43,13 +43,17 @@ class UserDiscoverySetupState { bool isManualApprovalEnabled; int requiredSendImages; - void Function(void Function()) setState; + void Function(void Function())? setState; void update(void Function() update) { update(); - setState(() { + if (setState != null) { + setState!(() { + wasChanged = true; + }); + } else { wasChanged = true; - }); + } } Future initializeOrUpdate() async { diff --git a/rust/src/log.rs b/rust/src/log.rs index 884aa04f..854a5351 100644 --- a/rust/src/log.rs +++ b/rust/src/log.rs @@ -34,10 +34,16 @@ pub(crate) async fn init_tracing(logs_dir: &std::path::Path, is_dart_available: // Replace stdout with our new DartWriter! + let default_filter = if std::env::var("FLUTTER_TEST").is_ok() { + "info,refinery_core=warn,refinery=warn" + } else { + "debug,refinery_core=warn,refinery=warn" + }; + let registry = Registry::default() .with( EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("debug,refinery_core=warn,refinery=warn")), + .unwrap_or_else(|_| EnvFilter::new(default_filter)), ) .with(stdout_layer); diff --git a/test/features/flame_counter_test.dart b/test/features/flame_counter_test.dart index 4f597547..2cc18de5 100644 --- a/test/features/flame_counter_test.dart +++ b/test/features/flame_counter_test.dart @@ -83,7 +83,8 @@ void main() { displayName: 'Test User', subscriptionPlan: 'Free', currentSetupPage: null, - )..appVersion = 62; + appVersion: 62, + ); }); test('test flame counter', () async { diff --git a/test/mocks/platform_channels.dart b/test/mocks/platform_channels.dart index 6587d33c..d75807df 100644 --- a/test/mocks/platform_channels.dart +++ b/test/mocks/platform_channels.dart @@ -9,9 +9,6 @@ void setupPlatformChannelMocks() { Future mockHandler(MethodCall methodCall) async { final userId = Zone.current[#userId] as int?; final keyPrefix = userId != null ? '${userId}_' : ''; - print( - 'DEBUG: mockHandler: method=${methodCall.method}, key=${methodCall.arguments?['key']}, userId=$userId, prefix=$keyPrefix', - ); if (methodCall.method == 'read') { final key = methodCall.arguments['key'] as String; return secureStorageMock[keyPrefix + key]; @@ -43,43 +40,38 @@ void setupPlatformChannelMocks() { return null; } - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel( - 'plugins.it_crowd.double_tapp/flutter_secure_storage', - ), - mockHandler, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), - mockHandler, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('dev.fluttercommunity.plus/connectivity'), - (call) async { - if (call.method == 'check') { - return ['wifi']; - } - return null; - }, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel( - 'be.tramesch.workmanager/foreground_channel_workmanager', - ), - (call) async => true, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.bbflight.background_downloader'), - (call) async { - if (call.method == 'enqueue') { - return true; - } - return null; - }, - ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel( + 'plugins.it_crowd.double_tapp/flutter_secure_storage', + ), + mockHandler, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + mockHandler, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async { + if (call.method == 'check') { + return ['wifi']; + } + return null; + }, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel( + 'be.tramesch.workmanager/foreground_channel_workmanager', + ), + (call) async => true, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('com.bbflight.background_downloader'), + (call) async { + if (call.method == 'enqueue') { + return true; + } + return null; + }, + ); } diff --git a/test/mocks/test_client.dart b/test/mocks/test_client.dart index 0b96af49..ea750335 100644 --- a/test/mocks/test_client.dart +++ b/test/mocks/test_client.dart @@ -79,9 +79,8 @@ class TestClient { displayName: username, subscriptionPlan: 'Free', currentSetupPage: null, + appVersion: 100, ); - // ignore: cascade_invocations - userData.appVersion = 100; await UserService.save(userData); await api.authenticate(); diff --git a/test/mocks/user_environment.dart b/test/mocks/user_environment.dart index c05eb7d3..264aa08b 100644 --- a/test/mocks/user_environment.dart +++ b/test/mocks/user_environment.dart @@ -96,7 +96,8 @@ class UserEnvironment { displayName: '$username Display', subscriptionPlan: 'Free', currentSetupPage: null, - )..appVersion = 100; + appVersion: 100, + ); // ignore: cascade_invocations us.isUserCreated = true; diff --git a/test/services/backup_service_test.dart b/test/services/backup_service_test.dart index 7f3c428c..331ccf4a 100644 --- a/test/services/backup_service_test.dart +++ b/test/services/backup_service_test.dart @@ -81,7 +81,8 @@ void main() { displayName: 'Test User', subscriptionPlan: 'Free', currentSetupPage: null, - )..appVersion = 100; + appVersion: 100, + ); userService.isUserCreated = true; await UserService.save(userService.currentUser); initialUserData = (await KeyValueStore.get('user'))!; diff --git a/test/services/group_service_test.dart b/test/services/group_service_test.dart index 2d603ae4..29065336 100644 --- a/test/services/group_service_test.dart +++ b/test/services/group_service_test.dart @@ -49,7 +49,8 @@ void main() { displayName: 'Test User', subscriptionPlan: 'Free', currentSetupPage: null, - )..appVersion = 100; + appVersion: 100, + ); userService.isUserCreated = true; AppEnvironment.initTesting(); // Log.init();