add promo for enabling e2ee backup
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-07-20 14:18:09 +02:00
parent 54538a6c31
commit fb9acc690a
10 changed files with 264 additions and 76 deletions

View file

@ -4171,6 +4171,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Share'** /// **'Share'**
String get galleryActionShare; String get galleryActionShare;
/// No description provided for @settingsStorageHidePromo.
///
/// In en, this message translates to:
/// **'Hide'**
String get settingsStorageHidePromo;
/// No description provided for @memoriesBackupTitle.
///
/// In en, this message translates to:
/// **'Memories Backup'**
String get memoriesBackupTitle;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2415,4 +2415,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get galleryActionShare => 'Teilen'; String get galleryActionShare => 'Teilen';
@override
String get settingsStorageHidePromo => 'Ausblenden';
@override
String get memoriesBackupTitle => 'Memories Backup';
} }

View file

@ -2394,4 +2394,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get galleryActionShare => 'Share'; String get galleryActionShare => 'Share';
@override
String get settingsStorageHidePromo => 'Hide';
@override
String get memoriesBackupTitle => 'Memories Backup';
} }

@ -1 +1 @@
Subproject commit 9d05713a1f69e7e4de742c414df7df6c4c7925a7 Subproject commit f72c61787096a44e2252058c08e4c14a0a8e5cff

View file

@ -103,6 +103,9 @@ class UserData {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
bool screenLockEnabled = false; bool screenLockEnabled = false;
@JsonKey(defaultValue: false)
bool isCloudBackupEnabled = false;
// > User Discovery Configurations // > User Discovery Configurations
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
@ -139,6 +142,9 @@ class UserData {
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool hideChangeLog = true; bool hideChangeLog = true;
@JsonKey(defaultValue: false)
bool hideMemoriesBackupPromo = false;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool updateFCMToken = true; bool updateFCMToken = true;
@ -150,8 +156,8 @@ class UserData {
@Deprecated('Use the secure storage in rust') @Deprecated('Use the secure storage in rust')
TwonlySafeBackup? twonlySafeBackup; TwonlySafeBackup? twonlySafeBackup;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: false)
bool isBackupEnabled = true; bool isBackupEnabled = false;
PasswordLessRecovery? passwordLessRecovery; PasswordLessRecovery? passwordLessRecovery;

View file

@ -68,6 +68,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..allowErrorTrackingViaSentry = ..allowErrorTrackingViaSentry =
json['allowErrorTrackingViaSentry'] as bool? ?? false json['allowErrorTrackingViaSentry'] as bool? ?? false
..screenLockEnabled = json['screenLockEnabled'] as bool? ?? false ..screenLockEnabled = json['screenLockEnabled'] as bool? ?? false
..isCloudBackupEnabled = json['isCloudBackupEnabled'] as bool? ?? false
..isUserDiscoveryEnabled = ..isUserDiscoveryEnabled =
json['isUserDiscoveryEnabled'] as bool? ?? false json['isUserDiscoveryEnabled'] as bool? ?? false
..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4 ..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4
@ -88,6 +89,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList() .toList()
..hideChangeLog = json['hideChangeLog'] as bool? ?? true ..hideChangeLog = json['hideChangeLog'] as bool? ?? true
..hideMemoriesBackupPromo =
json['hideMemoriesBackupPromo'] as bool? ?? false
..updateFCMToken = json['updateFCMToken'] as bool? ?? true ..updateFCMToken = json['updateFCMToken'] as bool? ?? true
..canUseLoginTokenForAuth = ..canUseLoginTokenForAuth =
json['canUseLoginTokenForAuth'] as bool? ?? true json['canUseLoginTokenForAuth'] as bool? ?? true
@ -96,7 +99,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
: TwonlySafeBackup.fromJson( : TwonlySafeBackup.fromJson(
json['twonlySafeBackup'] as Map<String, dynamic>, json['twonlySafeBackup'] as Map<String, dynamic>,
) )
..isBackupEnabled = json['isBackupEnabled'] as bool? ?? true ..isBackupEnabled = json['isBackupEnabled'] as bool? ?? false
..passwordLessRecovery = json['passwordLessRecovery'] == null ..passwordLessRecovery = json['passwordLessRecovery'] == null
? null ? null
: PasswordLessRecovery.fromJson( : PasswordLessRecovery.fromJson(
@ -153,6 +156,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
?.toIso8601String(), ?.toIso8601String(),
'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry, 'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry,
'screenLockEnabled': instance.screenLockEnabled, 'screenLockEnabled': instance.screenLockEnabled,
'isCloudBackupEnabled': instance.isCloudBackupEnabled,
'isUserDiscoveryEnabled': instance.isUserDiscoveryEnabled, 'isUserDiscoveryEnabled': instance.isUserDiscoveryEnabled,
'requiredSendImages': instance.requiredSendImages, 'requiredSendImages': instance.requiredSendImages,
'userDiscoveryThreshold': instance.userDiscoveryThreshold, 'userDiscoveryThreshold': instance.userDiscoveryThreshold,
@ -165,6 +169,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart, 'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
'lastChangeLogHash': instance.lastChangeLogHash, 'lastChangeLogHash': instance.lastChangeLogHash,
'hideChangeLog': instance.hideChangeLog, 'hideChangeLog': instance.hideChangeLog,
'hideMemoriesBackupPromo': instance.hideMemoriesBackupPromo,
'updateFCMToken': instance.updateFCMToken, 'updateFCMToken': instance.updateFCMToken,
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth, 'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
'twonlySafeBackup': instance.twonlySafeBackup, 'twonlySafeBackup': instance.twonlySafeBackup,

View file

@ -101,7 +101,7 @@ class MemoriesCloudService {
} }
Future<void> checkUploads() async { Future<void> checkUploads() async {
if (_isProcessing || !userService.currentUser.isBackupEnabled) return; if (_isProcessing || !userService.currentUser.isCloudBackupEnabled) return;
try { try {
final memories = await twonlyDB.mediaFilesDao.getMemoriesToBackup(); final memories = await twonlyDB.mediaFilesDao.getMemoriesToBackup();

View file

@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
class MemoriesCloudBackupPromoComp extends StatelessWidget {
const MemoriesCloudBackupPromoComp({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<void>(
stream: userService.onUserUpdated,
builder: (context, snapshot) {
final user = userService.currentUser;
if (user.isCloudBackupEnabled || user.hideMemoriesBackupPromo) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
}
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.color.primaryContainer.withValues(alpha: 0.15),
context.color.primaryContainer.withValues(alpha: 0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color: context.color.primary.withValues(alpha: 0.1),
width: 1.5,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.memoriesBackupTitle,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.color.onSurface,
),
),
const SizedBox(height: 4),
Text(
context.lang.settingsStorageNoCloudBackupCard,
style: TextStyle(
fontSize: 13,
color: context.color.onSurfaceVariant,
height: 1.3,
),
),
const SizedBox(height: 12),
Row(
children: [
MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: () async {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
unawaited(memoriesCloudService.checkUploads());
},
child: Text(context.lang.enable),
),
const SizedBox(width: 8),
MyButton(
variant: MyButtonVariant.secondaryDense,
onPressed: () async {
await UserService.update(
(u) => u.hideMemoriesBackupPromo = true,
);
},
child: Text(
context.lang.settingsStorageHidePromo,
),
),
],
),
],
),
),
],
),
),
),
);
},
);
}
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
@ -10,6 +11,7 @@ import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart'; import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart'; import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/views/memories/components/cloud_backup_promo.comp.dart';
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart'; import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart'; import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/components/selection_menu.comp.dart'; import 'package:twonly/src/visual/views/memories/components/selection_menu.comp.dart';
@ -680,6 +682,7 @@ class MemoriesViewState extends State<MemoriesView>
onOpenFlashback: (items, idx) => onOpenFlashback: (items, idx) =>
_openViewer(items, idx, isFlashback: true), _openViewer(items, idx, isFlashback: true),
), ),
const MemoriesCloudBackupPromoComp(),
for (final month in months) ...[ for (final month in months) ...[
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -9,7 +10,9 @@ import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/memories/memories_cloud.service.dart'; import 'package:twonly/src/services/memories/memories_cloud.service.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.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/elements/my_button.element.dart';
class ManageStorageView extends StatefulWidget { class ManageStorageView extends StatefulWidget {
const ManageStorageView({super.key}); const ManageStorageView({super.key});
@ -117,84 +120,123 @@ class _ManageStorageViewState extends State<ManageStorageView> {
const SizedBox(height: 24), const SizedBox(height: 24),
] else ...[ ] else ...[
Text( Text(
'Memories Backup', context.lang.memoriesBackupTitle,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( if (!userService.currentUser.isCloudBackupEnabled) ...[
_memoriesUsage != null Row(
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}' mainAxisAlignment: MainAxisAlignment.spaceBetween,
: '-', children: [
style: Theme.of(context).textTheme.headlineMedium?.copyWith( Expanded(
fontWeight: FontWeight.bold, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
),
const SizedBox(height: 24),
Container(
height: 24,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
if (_memoriesUsage == null ||
_memoriesUsage!.maxBytes == 0) {
return const SizedBox.shrink();
}
final maxWidth = constraints.maxWidth;
final current = _memoriesUsage!.currentBytes.toDouble();
final max = _memoriesUsage!.maxBytes.toDouble();
final usageWidth =
((current / max).clamp(0.0, 1.0)) * maxWidth;
return Row(
children: [ children: [
if (usageWidth > 0) Text(
Container( context.lang.settingsStorageNoCloudBackupCard,
width: usageWidth, style: Theme.of(context).textTheme.bodyMedium
color: Colors.blue, ?.copyWith(
), color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
], ],
); ),
}, ),
const SizedBox(width: 16),
MyButton(
variant: MyButtonVariant.primaryMiddle,
onPressed: () async {
await UserService.update(
(u) => u.isCloudBackupEnabled = true,
);
setState(() {});
unawaited(memoriesCloudService.checkUploads());
},
child: Text(context.lang.enable),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
] else ...[
Text(
_memoriesUsage != null
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
: '-',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
), ),
), ),
), const SizedBox(height: 24),
StreamBuilder<MemoriesBackupProgress>( Container(
initialData: memoriesCloudService.currentProgress, height: 24,
stream: memoriesCloudService.progressStream, width: double.infinity,
builder: (context, snapshot) { decoration: BoxDecoration(
final progress = snapshot.data; color: Colors.grey.withValues(alpha: 0.2),
if (progress == null || progress.totalPending == 0) { borderRadius: BorderRadius.circular(12),
return const SizedBox.shrink(); ),
} child: ClipRRect(
final percent = borderRadius: BorderRadius.circular(12),
(progress.currentUploaded / progress.totalPending) + child: LayoutBuilder(
(progress.currentUploadProgress / progress.totalPending); builder: (context, constraints) {
return Column( if (_memoriesUsage == null ||
crossAxisAlignment: CrossAxisAlignment.start, _memoriesUsage!.maxBytes == 0) {
children: [ return const SizedBox.shrink();
const SizedBox(height: 12), }
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)', final maxWidth = constraints.maxWidth;
style: const TextStyle(fontSize: 14), final current = _memoriesUsage!.currentBytes.toDouble();
), final max = _memoriesUsage!.maxBytes.toDouble();
const SizedBox(height: 6), final usageWidth =
LinearProgressIndicator( ((current / max).clamp(0.0, 1.0)) * maxWidth;
value: percent.clamp(0.0, 1.0),
), return Row(
], children: [
); if (usageWidth > 0)
}, Container(
), width: usageWidth,
const SizedBox(height: 24), color: Colors.blue,
const Divider(), ),
const SizedBox(height: 24), ],
);
},
),
),
),
StreamBuilder<MemoriesBackupProgress>(
initialData: memoriesCloudService.currentProgress,
stream: memoriesCloudService.progressStream,
builder: (context, snapshot) {
final progress = snapshot.data;
if (progress == null || progress.totalPending == 0) {
return const SizedBox.shrink();
}
final percent =
(progress.currentUploaded / progress.totalPending) +
(progress.currentUploadProgress / progress.totalPending);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Text(
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 6),
LinearProgressIndicator(
value: percent.clamp(0.0, 1.0),
),
],
);
},
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
],
], ],
Text( Text(
isFreePlan isFreePlan