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

View file

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

View file

@ -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';

View file

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

View file

@ -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*.';
}

View file

@ -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*.';
}

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

View file

@ -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<String, dynamic> json) =>
_$UserDataFromJson(json);
factory UserData.fromJson(Map<String, dynamic> 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<String, dynamic> json) =>
_$TwonlySafeBackupFromJson(json);
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) => _$TwonlySafeBackupFromJson(json);
int lastBackupSize = 0;
LastBackupUploadState backupUploadState = LastBackupUploadState.none;

View file

@ -13,13 +13,22 @@ UserData _$UserDataFromJson(Map<String, dynamic> 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<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'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<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'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',

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

View file

@ -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<SubscriptionPlan> get onPlanUpdated => _planUpdateController.stream;
final _connectionStateController = StreamController<bool>.broadcast();
Stream<bool> get onConnectionStateUpdated =>
_connectionStateController.stream;
Stream<bool> get onConnectionStateUpdated => _connectionStateController.stream;
final _appOutdatedController = StreamController<void>.broadcast();
Stream<void> get onAppOutdated => _appOutdatedController.stream;
final _newDeviceRegisteredController = StreamController<void>.broadcast();
Stream<void> get onNewDeviceRegistered =>
_newDeviceRegisteredController.stream;
Stream<void> get onNewDeviceRegistered => _newDeviceRegisteredController.stream;
bool appIsOutdated = false;
bool isAuthenticated = false;
@ -83,18 +80,12 @@ class ApiService {
Timer? reconnectionTimer;
int _reconnectionDelay = 5;
final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests =
HashMap();
final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests = HashMap();
IOWebSocketChannel? _channel;
// ignore: cancel_subscriptions
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<bool> _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<void> 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<void> _onDone() async {
if (kDebugMode) {
print('API _onDone called');
}
_reconnectionDelay = 3;
await onClosed();
}
Future<void> _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

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 {
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}',
);
}
}
});
}

View file

@ -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),

View file

@ -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();
});
},
),

View file

@ -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<void> 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<void> _processFaces(InputImage inputImage) async {
@ -465,6 +475,6 @@ class MainCameraController {
}
}
_isBusyFaces = false;
setState();
setState?.call();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SetupPages> 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<SetupView> {
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<SetupView> {
),
),
),
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<SetupView> {
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:

View file

@ -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<Color>(Colors.white),
),
)
: Text(
currentPage.isLast ? context.lang.finishSetup : context.lang.next,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
return StreamBuilder<void>(
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<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: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<PrivacyView> {
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),

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 {
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<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!
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);

View file

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

View file

@ -9,9 +9,6 @@ void setupPlatformChannelMocks() {
Future<dynamic> 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;
},
);
}

View file

@ -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();

View file

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

View file

@ -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'))!;

View file

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