improving onboarding flow and start with security profiles
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-05-20 03:24:00 +02:00
parent f42a49cadf
commit b7c4832ee2
36 changed files with 966 additions and 184 deletions

View file

@ -3,6 +3,7 @@
## 0.2.17 ## 0.2.17
- New: Adds an "Ask a Friend" button to new contact suggestions. - New: Adds an "Ask a Friend" button to new contact suggestions.
- New: Adds security profiles.
- Improved: Onboarding flow for new users. - Improved: Onboarding flow for new users.
- Improved: The blue verification checkmark now displays the total number of verifications. - Improved: The blue verification checkmark now displays the total number of verifications.
- Fix: Issue with receiving messages when user closed app while decrypting - Fix: Issue with receiving messages when user closed app while decrypting

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -15,7 +16,8 @@ class LoggingCallbacks {
Log.info(log.split('INFO ')[1]); Log.info(log.split('INFO ')[1]);
} else if (log.contains('DEBUG ')) { } else if (log.contains('DEBUG ')) {
Log.info(log.split('DEBUG ')[1]); Log.info(log.split('DEBUG ')[1]);
} else if (kDebugMode) { } else if (kDebugMode && !Platform.environment.containsKey('FLUTTER_TEST')) {
// ignore: avoid_print
print(log); print(log);
} }
}, },

View file

@ -35,6 +35,8 @@ class Routes {
'/settings/privacy/block_users'; '/settings/privacy/block_users';
static const String settingsPrivacyUserDiscovery = static const String settingsPrivacyUserDiscovery =
'/settings/privacy/user_discovery'; '/settings/privacy/user_discovery';
static const String settingsPrivacyProfileSelection =
'/settings/privacy/profile_selection';
static const String settingsNotification = '/settings/notification'; static const String settingsNotification = '/settings/notification';
static const String settingsStorage = '/settings/storage_data'; static const String settingsStorage = '/settings/storage_data';
static const String settingsStorageManage = '/settings/storage_data/manage'; static const String settingsStorageManage = '/settings/storage_data/manage';

View file

@ -626,6 +626,54 @@ abstract class AppLocalizations {
/// **'{len} contact(s)'** /// **'{len} contact(s)'**
String settingsPrivacyBlockUsersCount(Object len); 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. /// No description provided for @settingsNotification.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2441,7 +2489,7 @@ abstract class AppLocalizations {
/// No description provided for @userDiscoverySettingsManualApproval. /// No description provided for @userDiscoverySettingsManualApproval.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Ask before sharing'** /// **'Ask every time before sharing'**
String get userDiscoverySettingsManualApproval; String get userDiscoverySettingsManualApproval;
/// No description provided for @userDiscoverySettingsManualApprovalDesc. /// No description provided for @userDiscoverySettingsManualApprovalDesc.
@ -3355,6 +3403,60 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show username'** /// **'Show username'**
String get showUsername; 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 class _AppLocalizationsDelegate

View file

@ -292,6 +292,34 @@ class AppLocalizationsDe extends AppLocalizations {
return '$len Kontakt(e)'; 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 @override
String get settingsNotification => 'Benachrichtigung'; String get settingsNotification => 'Benachrichtigung';
@ -1334,7 +1362,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Erfahre, wer dich anfragt'; 'Erfahre, wer dich anfragt';
@override @override
String get userDiscoverySettingsManualApproval => 'Vor dem Teilen fragen'; String get userDiscoverySettingsManualApproval => 'Vor jedem Teilen fragen';
@override @override
String get userDiscoverySettingsManualApprovalDesc => String get userDiscoverySettingsManualApprovalDesc =>
@ -1908,4 +1936,35 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get showUsername => 'Benutzernamen anzeigen'; 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*.';
} }

View file

@ -288,6 +288,34 @@ class AppLocalizationsEn extends AppLocalizations {
return '$len contact(s)'; 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 @override
String get settingsNotification => 'Notification'; String get settingsNotification => 'Notification';
@ -1325,7 +1353,8 @@ class AppLocalizationsEn extends AppLocalizations {
'Be informed about who is requesting'; 'Be informed about who is requesting';
@override @override
String get userDiscoverySettingsManualApproval => 'Ask before sharing'; String get userDiscoverySettingsManualApproval =>
'Ask every time before sharing';
@override @override
String get userDiscoverySettingsManualApprovalDesc => String get userDiscoverySettingsManualApprovalDesc =>
@ -1892,4 +1921,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get showUsername => 'Show username'; 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*.';
} }

@ -1 +1 @@
Subproject commit f356d455e46d223ca2ea370892d23e54a6fe48c4 Subproject commit 18aa5e1afc76a20ded04e9d2c4321fec1c91183d

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:twonly/src/services/profile.service.dart';
part 'userdata.model.g.dart'; part 'userdata.model.g.dart';
@JsonSerializable() @JsonSerializable()
@ -10,9 +11,9 @@ class UserData {
required this.displayName, required this.displayName,
required this.subscriptionPlan, required this.subscriptionPlan,
required this.currentSetupPage, required this.currentSetupPage,
required this.appVersion,
}); });
factory UserData.fromJson(Map<String, dynamic> json) => factory UserData.fromJson(Map<String, dynamic> json) => _$UserDataFromJson(json);
_$UserDataFromJson(json);
final int userId; final int userId;
@ -35,6 +36,12 @@ class UserData {
@JsonKey(defaultValue: 0) @JsonKey(defaultValue: 0)
int deviceId = 0; int deviceId = 0;
@JsonKey(defaultValue: SetupProfile.standard)
SetupProfile setupProfile = SetupProfile.standard;
@JsonKey(defaultValue: SecurityProfile.normal)
SecurityProfile securityProfile = SecurityProfile.normal;
// --- SUBSCRIPTION DTA --- // --- SUBSCRIPTION DTA ---
@JsonKey(defaultValue: 'Free') @JsonKey(defaultValue: 'Free')
@ -179,8 +186,7 @@ class TwonlySafeBackup {
required this.backupId, required this.backupId,
required this.encryptionKey, required this.encryptionKey,
}); });
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) => factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) => _$TwonlySafeBackupFromJson(json);
_$TwonlySafeBackupFromJson(json);
int lastBackupSize = 0; int lastBackupSize = 0;
LastBackupUploadState backupUploadState = LastBackupUploadState.none; LastBackupUploadState backupUploadState = LastBackupUploadState.none;

View file

@ -13,13 +13,22 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String, displayName: json['displayName'] as String,
subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Free', subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Free',
currentSetupPage: json['currentSetupPage'] as String?, currentSetupPage: json['currentSetupPage'] as String?,
appVersion: (json['appVersion'] as num?)?.toInt() ?? 0,
) )
..avatarSvg = json['avatarSvg'] as String? ..avatarSvg = json['avatarSvg'] as String?
..avatarJson = json['avatarJson'] as String? ..avatarJson = json['avatarJson'] as String?
..appVersion = (json['appVersion'] as num?)?.toInt() ?? 0
..avatarCounter = (json['avatarCounter'] as num?)?.toInt() ?? 0 ..avatarCounter = (json['avatarCounter'] as num?)?.toInt() ?? 0
..isDeveloper = json['isDeveloper'] as bool? ?? false ..isDeveloper = json['isDeveloper'] as bool? ?? false
..deviceId = (json['deviceId'] as num?)?.toInt() ?? 0 ..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? ..subscriptionPlanIdStore = json['subscriptionPlanIdStore'] as String?
..lastImageSend = json['lastImageSend'] == null ..lastImageSend = json['lastImageSend'] == null
? null ? null
@ -115,6 +124,8 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'avatarCounter': instance.avatarCounter, 'avatarCounter': instance.avatarCounter,
'isDeveloper': instance.isDeveloper, 'isDeveloper': instance.isDeveloper,
'deviceId': instance.deviceId, 'deviceId': instance.deviceId,
'setupProfile': _$SetupProfileEnumMap[instance.setupProfile]!,
'securityProfile': _$SecurityProfileEnumMap[instance.securityProfile]!,
'subscriptionPlan': instance.subscriptionPlan, 'subscriptionPlan': instance.subscriptionPlan,
'subscriptionPlanIdStore': instance.subscriptionPlanIdStore, 'subscriptionPlanIdStore': instance.subscriptionPlanIdStore,
'lastImageSend': instance.lastImageSend?.toIso8601String(), 'lastImageSend': instance.lastImageSend?.toIso8601String(),
@ -168,6 +179,17 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'hasZoomed': instance.hasZoomed, 'hasZoomed': instance.hasZoomed,
}; };
const _$SetupProfileEnumMap = {
SetupProfile.standard: 'standard',
SetupProfile.customized: 'customized',
SetupProfile.maximum: 'maximum',
};
const _$SecurityProfileEnumMap = {
SecurityProfile.normal: 'normal',
SecurityProfile.strict: 'strict',
};
const _$ThemeModeEnumMap = { const _$ThemeModeEnumMap = {
ThemeMode.system: 'system', ThemeMode.system: 'system',
ThemeMode.light: 'light', ThemeMode.light: 'light',

View file

@ -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/notification.view.dart';
import 'package:twonly/src/visual/views/settings/privacy.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/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/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/modify_avatar.view.dart';
import 'package:twonly/src/visual/views/settings/profile/profile.view.dart'; import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
@ -205,6 +206,10 @@ final routerProvider = GoRouter(
path: 'user_discovery', path: 'user_discovery',
builder: (context, state) => const UserDiscoverySettingsView(), builder: (context, state) => const UserDiscoverySettingsView(),
), ),
GoRoute(
path: 'profile_selection',
builder: (context, state) => const ProfileSelectionSettingsView(),
),
], ],
), ),
GoRoute( GoRoute(

View file

@ -21,8 +21,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.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/client_to_server.pbserver.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.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' import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' as server;
as server;
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; 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/client2client/user_discovery.c2c.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart';
@ -66,15 +65,13 @@ class ApiService {
Stream<SubscriptionPlan> get onPlanUpdated => _planUpdateController.stream; Stream<SubscriptionPlan> get onPlanUpdated => _planUpdateController.stream;
final _connectionStateController = StreamController<bool>.broadcast(); final _connectionStateController = StreamController<bool>.broadcast();
Stream<bool> get onConnectionStateUpdated => Stream<bool> get onConnectionStateUpdated => _connectionStateController.stream;
_connectionStateController.stream;
final _appOutdatedController = StreamController<void>.broadcast(); final _appOutdatedController = StreamController<void>.broadcast();
Stream<void> get onAppOutdated => _appOutdatedController.stream; Stream<void> get onAppOutdated => _appOutdatedController.stream;
final _newDeviceRegisteredController = StreamController<void>.broadcast(); final _newDeviceRegisteredController = StreamController<void>.broadcast();
Stream<void> get onNewDeviceRegistered => Stream<void> get onNewDeviceRegistered => _newDeviceRegisteredController.stream;
_newDeviceRegisteredController.stream;
bool appIsOutdated = false; bool appIsOutdated = false;
bool isAuthenticated = false; bool isAuthenticated = false;
@ -83,18 +80,12 @@ class ApiService {
Timer? reconnectionTimer; Timer? reconnectionTimer;
int _reconnectionDelay = 5; int _reconnectionDelay = 5;
final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests = final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests = HashMap();
HashMap();
IOWebSocketChannel? _channel; IOWebSocketChannel? _channel;
// ignore: cancel_subscriptions // ignore: cancel_subscriptions
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription; StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<bool> _connectTo(String apiUrl) async { Future<bool> _connectTo(String apiUrl) async {
if (kDebugMode) {
print(
'DEBUG: ApiService._connectTo called with: $apiUrl (appIsOutdated=$appIsOutdated)',
);
}
if (appIsOutdated) return false; if (appIsOutdated) return false;
try { try {
final channel = IOWebSocketChannel.connect( final channel = IOWebSocketChannel.connect(
@ -113,11 +104,8 @@ class ApiService {
_channel!.stream.listen(_onData, onDone: _onDone, onError: _onError); _channel!.stream.listen(_onData, onDone: _onDone, onError: _onError);
Log.info('websocket connected to $apiUrl'); Log.info('websocket connected to $apiUrl');
return true; return true;
} catch (e, s) { } catch (e) {
_channel = null; _channel = null;
if (kDebugMode) {
print('DEBUG: _connectTo caught exception: $e\n$s');
}
return false; return false;
} }
} }
@ -164,9 +152,6 @@ class ApiService {
} }
Future<void> onClosed() async { Future<void> onClosed() async {
if (kDebugMode) {
print('API onClosed called');
}
if (_channel == null) return; if (_channel == null) return;
Log.info('websocket connection closed'); Log.info('websocket connection closed');
_channel = null; _channel = null;
@ -254,17 +239,11 @@ class ApiService {
bool get isConnected => _channel != null && _channel!.closeCode == null; bool get isConnected => _channel != null && _channel!.closeCode == null;
Future<void> _onDone() async { Future<void> _onDone() async {
if (kDebugMode) {
print('API _onDone called');
}
_reconnectionDelay = 3; _reconnectionDelay = 3;
await onClosed(); await onClosed();
} }
Future<void> _onError(dynamic e) async { Future<void> _onError(dynamic e) async {
if (kDebugMode) {
print('API _onError called: $e');
}
if (e.toString().contains('Failed host lookup')) { if (e.toString().contains('Failed host lookup')) {
Log.info('WebSocket connection failed: Host not reachable.'); Log.info('WebSocket connection failed: Host not reachable.');
} else { } else {
@ -439,9 +418,7 @@ class ApiService {
} }
if (res.error == ErrorCode.UserIdNotFound && contactId != null) { if (res.error == ErrorCode.UserIdNotFound && contactId != null) {
Log.warn('Contact deleted their account $contactId.'); Log.warn('Contact deleted their account $contactId.');
final contact = await twonlyDB.contactsDao final contact = await twonlyDB.contactsDao.getContactByUserId(contactId).getSingleOrNull();
.getContactByUserId(contactId)
.getSingleOrNull();
if (contact != null) { if (contact != null) {
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
contactId, contactId,
@ -506,8 +483,7 @@ class ApiService {
return true; return true;
} }
if (result.isError) { if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid && if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) {
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error( Log.error(
'got error while authenticating to the server: ${result.error}', 'got error while authenticating to the server: ${result.error}',
); );
@ -545,8 +521,7 @@ class ApiService {
return true; return true;
} }
if (result.isError) { if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid && if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) {
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error( Log.error(
'got error while authenticating to the server: ${result.error}', 'got error while authenticating to the server: ${result.error}',
); );
@ -578,8 +553,7 @@ class ApiService {
return; return;
} }
final handshake = Handshake() final handshake = Handshake()..getAuthChallenge = Handshake_GetAuthChallenge();
..getAuthChallenge = Handshake_GetAuthChallenge();
final req = createClientToServerFromHandshake(handshake); final req = createClientToServerFromHandshake(handshake);
final result = await sendRequestSync(req, authenticated: false); final result = await sendRequestSync(req, authenticated: false);
@ -644,9 +618,7 @@ class ApiService {
final register = Handshake_Register() final register = Handshake_Register()
..username = username ..username = username
..publicIdentityKey = (await signalStore.getIdentityKeyPair()) ..publicIdentityKey = (await signalStore.getIdentityKeyPair()).getPublicKey().serialize()
.getPublicKey()
.serialize()
..registrationId = Int64(signalIdentity.registrationId) ..registrationId = Int64(signalIdentity.registrationId)
..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize() ..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize()
..signedPrekeySignature = signedPreKey.signature ..signedPrekeySignature = signedPreKey.signature

View file

@ -0,0 +1,7 @@
enum SetupProfile { standard, customized, maximum }
enum SecurityProfile { normal, strict }
extension SecurityProfileExtension on SecurityProfile {
bool get showWarningForNonVerifiedContacts => this == SecurityProfile.strict;
}

View file

@ -18,10 +18,13 @@ class Log {
Logger.root.onRecord.listen((record) async { Logger.root.onRecord.listen((record) async {
unawaited(_writeLogToFile(record)); unawaited(_writeLogToFile(record));
if (!kReleaseMode) { if (!kReleaseMode) {
// ignore: avoid_print if (!Platform.environment.containsKey('FLUTTER_TEST') ||
print( record.level >= Level.WARNING) {
'${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}', // ignore: avoid_print
); print(
'${record.level.name} [${AppState.isInBackgroundTask ? 'b' : 'f'}] [twonly] ${record.loggerName} > ${record.message}',
);
}
} }
}); });
} }

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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/utils/misc.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
@ -23,16 +25,18 @@ class VerificationBadgeInfo extends StatelessWidget {
description: context.lang.verificationBadgeGreenDesc, description: context.lang.verificationBadgeGreenDesc,
boldTextColor: primaryColor, boldTextColor: primaryColor,
), ),
_buildItem( if (userService.currentUser.securityProfile != SecurityProfile.strict ||
context, userService.currentUser.isUserDiscoveryEnabled)
icon: const SvgIcon( _buildItem(
assetPath: SvgIcons.verifiedGreen, context,
size: 40, icon: const SvgIcon(
color: colorVerificationBadgeYellow, assetPath: SvgIcons.verifiedGreen,
size: 40,
color: colorVerificationBadgeYellow,
),
description: context.lang.verificationBadgeYellowDesc,
boldTextColor: colorVerificationBadgeYellow,
), ),
description: context.lang.verificationBadgeYellowDesc,
boldTextColor: colorVerificationBadgeYellow,
),
_buildItem( _buildItem(
context, context,
icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40), icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40),

View file

@ -33,8 +33,7 @@ class CameraScannedOverlay extends StatelessWidget {
...mainController.contactsVerified.values.map( ...mainController.contactsVerified.values.map(
(c) => _buildVerifiedContactTile(context, c), (c) => _buildVerifiedContactTile(context, c),
), ),
if (mainController.scannedUrl != null) if (mainController.scannedUrl != null) _buildScannedUrlTile(context, mainController.scannedUrl!),
_buildScannedUrlTile(context, mainController.scannedUrl!),
], ],
), ),
), ),
@ -46,15 +45,14 @@ class CameraScannedOverlay extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () async { onTap: () async {
c.isLoading = true; c.isLoading = true;
mainController.setState(); mainController.setState?.call();
showSnackbar( showSnackbar(
context, context,
context.lang.requestedUserToastText(c.profile.username), context.lang.requestedUserToastText(c.profile.username),
level: SnackbarLevel.success, level: SnackbarLevel.success,
); );
if (await addNewContactFromPublicProfile(c.profile) && if (await addNewContactFromPublicProfile(c.profile) && context.mounted) {
context.mounted) {
// showSnackbar( // showSnackbar(
// context, // context,
// context.lang.requestedUserToastText(c.profile.username), // context.lang.requestedUserToastText(c.profile.username),
@ -121,13 +119,11 @@ class CameraScannedOverlay extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 30, width: 30,
child: Lottie.asset( child: Lottie.asset(
c.verificationOk c.verificationOk ? 'assets/animations/success.lottie' : 'assets/animations/failed.lottie',
? 'assets/animations/success.lottie'
: 'assets/animations/failed.lottie',
repeat: false, repeat: false,
onLoaded: (p0) { onLoaded: (p0) {
Future.delayed(const Duration(seconds: 4), () { Future.delayed(const Duration(seconds: 4), () {
mainController.setState(); mainController.setState?.call();
}); });
}, },
), ),

View file

@ -40,7 +40,7 @@ class ScannedNewProfile {
} }
class MainCameraController { class MainCameraController {
late void Function() setState; void Function()? setState;
CameraController? cameraController; CameraController? cameraController;
ScreenshotController screenshotController = ScreenshotController(); ScreenshotController screenshotController = ScreenshotController();
SelectedCameraDetails selectedCameraDetails = SelectedCameraDetails(); SelectedCameraDetails selectedCameraDetails = SelectedCameraDetails();
@ -61,12 +61,12 @@ class MainCameraController {
void setSharedLinkForPreview(Uri? url) { void setSharedLinkForPreview(Uri? url) {
sharedLinkForPreview = url; sharedLinkForPreview = url;
setState(); setState?.call();
} }
void onImageSend() { void onImageSend() {
scannedUrl = ''; scannedUrl = '';
setState(); setState?.call();
} }
final BarcodeScanner _barcodeScanner = BarcodeScanner(); final BarcodeScanner _barcodeScanner = BarcodeScanner();
@ -115,6 +115,7 @@ class MainCameraController {
); );
initCameraStarted = false; initCameraStarted = false;
selectedCameraDetails = SelectedCameraDetails(); selectedCameraDetails = SelectedCameraDetails();
setState?.call();
} }
Future<void> selectCamera(int sCameraId, bool init) async { Future<void> selectCamera(int sCameraId, bool init) async {
@ -153,8 +154,11 @@ class MainCameraController {
try { try {
_initializeFuture = cameraController?.initialize(); _initializeFuture = cameraController?.initialize();
await _initializeFuture; await _initializeFuture;
if (cameraController == null) return;
await cameraController?.startImageStream(_processCameraImage); await cameraController?.startImageStream(_processCameraImage);
if (cameraController == null) return;
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor); await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
if (cameraController == null) return;
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) { if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
await cameraController?.setVideoStabilizationMode( await cameraController?.setVideoStabilizationMode(
VideoStabilizationMode.level1, VideoStabilizationMode.level1,
@ -172,10 +176,13 @@ class MainCameraController {
} catch (e) { } catch (e) {
Log.info(e); Log.info(e);
} }
if (cameraController == null) return;
selectedCameraDetails.scaleFactor = 1; selectedCameraDetails.scaleFactor = 1;
await cameraController?.setZoomLevel(1); await cameraController?.setZoomLevel(1);
if (cameraController == null) return;
await cameraController?.setDescription(AppEnvironment.cameras[cameraId]); await cameraController?.setDescription(AppEnvironment.cameras[cameraId]);
if (cameraController == null) return;
try { try {
if (!isVideoRecording) { if (!isVideoRecording) {
await cameraController?.startImageStream(_processCameraImage); await cameraController?.startImageStream(_processCameraImage);
@ -186,12 +193,15 @@ class MainCameraController {
} }
try { try {
if (cameraController == null) return;
await cameraController?.lockCaptureOrientation( await cameraController?.lockCaptureOrientation(
DeviceOrientation.portraitUp, DeviceOrientation.portraitUp,
); );
if (cameraController == null) return;
await cameraController?.setFlashMode( await cameraController?.setFlashMode(
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off, selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
); );
if (cameraController == null) return;
selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1; selectedCameraDetails.maxAvailableZoom = await cameraController?.getMaxZoomLevel() ?? 1;
selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1; selectedCameraDetails.minAvailableZoom = await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails selectedCameraDetails
@ -204,7 +214,7 @@ class MainCameraController {
isSelectingFaceFilters = false; isSelectingFaceFilters = false;
setFilter(FaceFilterType.none); setFilter(FaceFilterType.none);
zoomButtonKey = GlobalKey(); zoomButtonKey = GlobalKey();
setState(); setState?.call();
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
cameraController = null; cameraController = null;
@ -226,7 +236,7 @@ class MainCameraController {
final dx = (localPosition.dx / box.size.width).clamp(0.0, 1.0); final dx = (localPosition.dx / box.size.width).clamp(0.0, 1.0);
final dy = (localPosition.dy / box.size.height).clamp(0.0, 1.0); final dy = (localPosition.dy / box.size.height).clamp(0.0, 1.0);
setState(); setState?.call();
await HapticFeedback.lightImpact(); await HapticFeedback.lightImpact();
try { try {
@ -244,7 +254,7 @@ class MainCameraController {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
focusPointOffset = null; focusPointOffset = null;
setState(); setState?.call();
} }
void setFilter(FaceFilterType type) { void setFilter(FaceFilterType type) {
@ -254,7 +264,7 @@ class MainCameraController {
facePaint = null; facePaint = null;
_isBusyFaces = false; _isBusyFaces = false;
} }
setState(); setState?.call();
} }
FaceFilterPainter? faceFilterPainter; FaceFilterPainter? faceFilterPainter;
@ -419,7 +429,7 @@ class MainCameraController {
} }
} }
_isBusy = false; _isBusy = false;
setState(); setState?.call();
} }
Future<void> _processFaces(InputImage inputImage) async { Future<void> _processFaces(InputImage inputImage) async {
@ -465,6 +475,6 @@ class MainCameraController {
} }
} }
_isBusyFaces = false; _isBusyFaces = false;
setState(); setState?.call();
} }
} }

View file

@ -24,6 +24,7 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
@override @override
void dispose() { void dispose() {
_mainCameraController.setState = null;
_mainCameraController.closeCamera(); _mainCameraController.closeCamera();
super.dispose(); super.dispose();
} }

View file

@ -26,6 +26,7 @@ class CameraSendToViewState extends State<CameraSendToView> {
@override @override
void dispose() { void dispose() {
_mainCameraController.setState = null;
_mainCameraController.closeCamera(); _mainCameraController.closeCamera();
super.dispose(); super.dispose();
} }

View file

@ -38,7 +38,7 @@ class _ChatListViewState extends State<ChatListView> {
List<Group> _groupsPinned = []; List<Group> _groupsPinned = [];
List<Group> _groupsArchived = []; List<Group> _groupsArchived = [];
bool _hasContacts = true; bool _hasContacts = false;
bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty; bool get _hasOpenGroup => _groupsNotPinned.isNotEmpty || _groupsArchived.isNotEmpty || _groupsPinned.isNotEmpty;
GlobalKey searchForOtherUsers = GlobalKey(); GlobalKey searchForOtherUsers = GlobalKey();

View file

@ -169,6 +169,7 @@ class HomeViewState extends State<HomeView> {
_homeViewPageIndexSub?.cancel(); _homeViewPageIndexSub?.cancel();
_selectNotificationSub?.cancel(); _selectNotificationSub?.cancel();
_disableCameraTimer?.cancel(); _disableCameraTimer?.cancel();
_mainCameraController.setState = null;
_mainCameraController.closeCamera(); _mainCameraController.closeCamera();
_intentStreamSub?.cancel(); _intentStreamSub?.cancel();
_deepLinkSub?.cancel(); _deepLinkSub?.cancel();

View file

@ -140,7 +140,8 @@ class _RegisterViewState extends State<RegisterView> {
displayName: username, displayName: username,
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name, currentSetupPage: SetupPages.profile.name,
)..appVersion = AppState.latestAppVersionId; appVersion: AppState.latestAppVersionId,
);
await UserService.save(userData); await UserService.save(userData);

View file

@ -2,11 +2,14 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/locator.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/services/user.service.dart';
import 'package:twonly/src/utils/misc.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/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/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.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/share_your_friends.setup.dart';
import 'package:twonly/src/visual/views/onboarding/setup/verification_badge.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'; 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 { enum SetupPages {
profile, profile,
backup, backup,
profileSelection,
securityProfile,
verificationBadge, verificationBadge,
shareYourFriends, shareYourFriends,
letYourFriendsFindYou, letYourFriendsFindYou,
@ -27,25 +32,62 @@ extension SetupPagesExtension on SetupPages {
); );
} }
int get pageNumber => index + 1; static List<SetupPages> get activePages {
int get totalPages => SetupPages.values.length; 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(); int get progressPercentage => ((pageNumber - 1) / totalPages * 100).round();
String get progressText => '$pageNumber / $totalPages'; String get progressText => '$pageNumber / $totalPages';
bool get isLast => index == SetupPages.values.length - 1; bool get isLast {
return activePages.isNotEmpty && activePages.last == this;
}
SetupPages? next() { SetupPages? next() {
final nextIndex = index + 1; final pages = activePages;
if (nextIndex < SetupPages.values.length) { final idx = pages.indexOf(this);
return SetupPages.values[nextIndex]; if (idx != -1 && idx + 1 < pages.length) {
return pages[idx + 1];
} }
return null; return null;
} }
SetupPages? previous() { SetupPages? previous() {
final prevIndex = index - 1; final pages = activePages;
if (prevIndex >= 0) { final idx = pages.indexOf(this);
return SetupPages.values[prevIndex]; if (idx > 0) {
return pages[idx - 1];
} }
return null; return null;
} }
@ -110,9 +152,7 @@ class _SetupViewState extends State<SetupView> {
right: index == currentPage.totalPages - 1 ? 0 : 8, right: index == currentPage.totalPages - 1 ? 0 : 8,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isFinished color: isFinished ? context.color.primary : context.color.surfaceContainer,
? context.color.primary
: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
), ),
@ -149,8 +189,7 @@ class _SetupViewState extends State<SetupView> {
), ),
), ),
), ),
if (currentPage.index > 0 && !currentPage.isLast) if (currentPage.index > 0 && !currentPage.isLast) const SizedBox(width: 24),
const SizedBox(width: 24),
if (!currentPage.isLast) if (!currentPage.isLast)
TextButton( TextButton(
onPressed: () async { onPressed: () async {
@ -183,6 +222,10 @@ class _SetupViewState extends State<SetupView> {
return const ProfileSetupPage(); return const ProfileSetupPage();
case SetupPages.backup: case SetupPages.backup:
return const BackupSetupPage(); return const BackupSetupPage();
case SetupPages.profileSelection:
return const ProfileSelectionSetup();
case SetupPages.securityProfile:
return const SecurityProfileSetup();
case SetupPages.verificationBadge: case SetupPages.verificationBadge:
return const VerificationBadgeSetupPage(); return const VerificationBadgeSetupPage();
case SetupPages.shareYourFriends: case SetupPages.shareYourFriends:

View file

@ -18,43 +18,48 @@ class NextButtonComp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentPage = SetupPagesExtension.fromStr( return StreamBuilder<void>(
userService.currentUser.currentSetupPage, stream: userService.onUserUpdated,
); builder: (context, snapshot) {
return ElevatedButton( final currentPage = SetupPagesExtension.fromStr(
onPressed: (canSubmit && !isLoading) userService.currentUser.currentSetupPage,
? () async { );
if (onPressed != null) { return ElevatedButton(
final error = await onPressed?.call(); onPressed: (canSubmit && !isLoading)
if (error == true) return; ? () async {
} if (onPressed != null) {
await UserService.update((user) { final error = await onPressed?.call();
user.currentSetupPage = currentPage.next()?.name; if (error == true) return;
}); }
} await UserService.update((user) {
: null, user.currentSetupPage = currentPage.next()?.name;
style: ElevatedButton.styleFrom( });
minimumSize: const Size(double.infinity, 56), }
backgroundColor: context.color.primary, : null,
foregroundColor: context.color.onPrimary, style: ElevatedButton.styleFrom(
elevation: 0, minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder( backgroundColor: context.color.primary,
borderRadius: BorderRadius.circular(16), foregroundColor: context.color.onPrimary,
), elevation: 0,
), shape: RoundedRectangleBorder(
child: isLoading borderRadius: BorderRadius.circular(16),
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
),
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
);
},
); );
} }
} }

View file

@ -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<bool>? 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,
],
),
),
],
),
),
),
);
}
}

View file

@ -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<ProfileSelectionSetup> createState() => _ProfileSelectionSetupState();
}
class _ProfileSelectionSetupState extends State<ProfileSelectionSetup> {
SetupProfile? _hoveredProfile;
bool _isLoading = false;
Future<void> _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<void>(
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;
},
),
],
);
},
);
}
}

View file

@ -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<SecurityProfileSetup> createState() => _SecurityProfileSetupState();
}
class _SecurityProfileSetupState extends State<SecurityProfileSetup> {
SecurityProfile? _hoveredProfile;
Future<void> _onProfileTapped(SecurityProfile profile) async {
await UserService.update((user) {
user.securityProfile = profile;
});
}
@override
Widget build(BuildContext context) {
return StreamBuilder<void>(
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(),
],
);
},
);
}
}

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.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/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -78,6 +79,18 @@ class _PrivacyViewState extends State<PrivacyView> {
setState(() {}); 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(), const Divider(),
ListTile( ListTile(
title: Text(context.lang.settingsTypingIndication), title: Text(context.lang.settingsTypingIndication),

View file

@ -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<ProfileSelectionSettingsView> createState() =>
_ProfileSelectionSettingsViewState();
}
class _ProfileSelectionSettingsViewState
extends State<ProfileSelectionSettingsView> {
SecurityProfile? _hoveredProfile;
Future<void> _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<void>(
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),
),
],
);
},
),
),
),
);
}
}

View file

@ -26,7 +26,7 @@ List<String> getExampleUsers(BuildContext context) => [
class UserDiscoverySetupState { class UserDiscoverySetupState {
UserDiscoverySetupState({ UserDiscoverySetupState({
required this.setState, this.setState,
this.isUserDiscoveryEnabled = true, this.isUserDiscoveryEnabled = true,
this.sharePromotion = true, this.sharePromotion = true,
this.isManualApprovalEnabled = false, this.isManualApprovalEnabled = false,
@ -43,13 +43,17 @@ class UserDiscoverySetupState {
bool isManualApprovalEnabled; bool isManualApprovalEnabled;
int requiredSendImages; int requiredSendImages;
void Function(void Function()) setState; void Function(void Function())? setState;
void update(void Function() update) { void update(void Function() update) {
update(); update();
setState(() { if (setState != null) {
setState!(() {
wasChanged = true;
});
} else {
wasChanged = true; wasChanged = true;
}); }
} }
Future<bool> initializeOrUpdate() async { Future<bool> initializeOrUpdate() async {

View file

@ -34,10 +34,16 @@ pub(crate) async fn init_tracing(logs_dir: &std::path::Path, is_dart_available:
// Replace stdout with our new DartWriter! // 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() let registry = Registry::default()
.with( .with(
EnvFilter::try_from_default_env() 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); .with(stdout_layer);

View file

@ -83,7 +83,8 @@ void main() {
displayName: 'Test User', displayName: 'Test User',
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: null, currentSetupPage: null,
)..appVersion = 62; appVersion: 62,
);
}); });
test('test flame counter', () async { test('test flame counter', () async {

View file

@ -9,9 +9,6 @@ void setupPlatformChannelMocks() {
Future<dynamic> mockHandler(MethodCall methodCall) async { Future<dynamic> mockHandler(MethodCall methodCall) async {
final userId = Zone.current[#userId] as int?; final userId = Zone.current[#userId] as int?;
final keyPrefix = userId != null ? '${userId}_' : ''; final keyPrefix = userId != null ? '${userId}_' : '';
print(
'DEBUG: mockHandler: method=${methodCall.method}, key=${methodCall.arguments?['key']}, userId=$userId, prefix=$keyPrefix',
);
if (methodCall.method == 'read') { if (methodCall.method == 'read') {
final key = methodCall.arguments['key'] as String; final key = methodCall.arguments['key'] as String;
return secureStorageMock[keyPrefix + key]; return secureStorageMock[keyPrefix + key];
@ -43,43 +40,38 @@ void setupPlatformChannelMocks() {
return null; return null;
} }
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
.setMockMethodCallHandler( const MethodChannel(
const MethodChannel( 'plugins.it_crowd.double_tapp/flutter_secure_storage',
'plugins.it_crowd.double_tapp/flutter_secure_storage', ),
), mockHandler,
mockHandler, );
); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
.setMockMethodCallHandler( mockHandler,
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), );
mockHandler, TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
); const MethodChannel('dev.fluttercommunity.plus/connectivity'),
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger (call) async {
.setMockMethodCallHandler( if (call.method == 'check') {
const MethodChannel('dev.fluttercommunity.plus/connectivity'), return ['wifi'];
(call) async { }
if (call.method == 'check') { return null;
return ['wifi']; },
} );
return null; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
}, const MethodChannel(
); 'be.tramesch.workmanager/foreground_channel_workmanager',
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger ),
.setMockMethodCallHandler( (call) async => true,
const MethodChannel( );
'be.tramesch.workmanager/foreground_channel_workmanager', TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
), const MethodChannel('com.bbflight.background_downloader'),
(call) async => true, (call) async {
); if (call.method == 'enqueue') {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger return true;
.setMockMethodCallHandler( }
const MethodChannel('com.bbflight.background_downloader'), return null;
(call) async { },
if (call.method == 'enqueue') { );
return true;
}
return null;
},
);
} }

View file

@ -79,9 +79,8 @@ class TestClient {
displayName: username, displayName: username,
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: null, currentSetupPage: null,
appVersion: 100,
); );
// ignore: cascade_invocations
userData.appVersion = 100;
await UserService.save(userData); await UserService.save(userData);
await api.authenticate(); await api.authenticate();

View file

@ -96,7 +96,8 @@ class UserEnvironment {
displayName: '$username Display', displayName: '$username Display',
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: null, currentSetupPage: null,
)..appVersion = 100; appVersion: 100,
);
// ignore: cascade_invocations // ignore: cascade_invocations
us.isUserCreated = true; us.isUserCreated = true;

View file

@ -81,7 +81,8 @@ void main() {
displayName: 'Test User', displayName: 'Test User',
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: null, currentSetupPage: null,
)..appVersion = 100; appVersion: 100,
);
userService.isUserCreated = true; userService.isUserCreated = true;
await UserService.save(userService.currentUser); await UserService.save(userService.currentUser);
initialUserData = (await KeyValueStore.get('user'))!; initialUserData = (await KeyValueStore.get('user'))!;

View file

@ -49,7 +49,8 @@ void main() {
displayName: 'Test User', displayName: 'Test User',
subscriptionPlan: 'Free', subscriptionPlan: 'Free',
currentSetupPage: null, currentSetupPage: null,
)..appVersion = 100; appVersion: 100,
);
userService.isUserCreated = true; userService.isUserCreated = true;
AppEnvironment.initTesting(); AppEnvironment.initTesting();
// Log.init(); // Log.init();