diff --git a/go-backend/internal/handlers/account_handler.go b/go-backend/internal/handlers/account_handler.go
index 0df5f56..132e65e 100644
--- a/go-backend/internal/handlers/account_handler.go
+++ b/go-backend/internal/handlers/account_handler.go
@@ -51,6 +51,18 @@ func (h *AccountHandler) DeactivateAccount(c *gin.Context) {
// Revoke all tokens so they're logged out everywhere
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
+ // Send notification email
+ profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
+ displayName := "there"
+ if profile != nil && profile.DisplayName != nil {
+ displayName = *profile.DisplayName
+ }
+ go func() {
+ if err := h.sendDeactivationEmail(user.Email, displayName); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deactivation email")
+ }
+ }()
+
log.Info().Str("user_id", userID).Msg("Account deactivated")
c.JSON(http.StatusOK, gin.H{
"message": "Your account has been deactivated. All your data is preserved. You can reactivate at any time by logging back in.",
@@ -75,6 +87,21 @@ func (h *AccountHandler) DeleteAccount(c *gin.Context) {
deletionDate := time.Now().Add(14 * 24 * time.Hour).Format("January 2, 2006")
+ // Send notification email
+ user, err := h.repo.GetUserByID(c.Request.Context(), userID)
+ if err == nil {
+ profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
+ displayName := "there"
+ if profile != nil && profile.DisplayName != nil {
+ displayName = *profile.DisplayName
+ }
+ go func() {
+ if err := h.sendDeletionScheduledEmail(user.Email, displayName, deletionDate); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deletion email")
+ }
+ }()
+ }
+
log.Info().Str("user_id", userID).Msg("Account scheduled for deletion in 14 days")
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Your account is scheduled for permanent deletion on %s. Log back in before then to cancel. After that date, all data will be irreversibly destroyed.", deletionDate),
@@ -240,6 +267,114 @@ func generateDestroyToken() (string, error) {
return hex.EncodeToString(b), nil
}
+func (h *AccountHandler) sendDeactivationEmail(toEmail, toName string) error {
+ subject := "Your Sojorn account has been deactivated"
+
+ htmlBody := fmt.Sprintf(`
+
+
+
+
+
+
Account Deactivated
+
+
Hey %s,
+
+
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
+
+
+
What this means:
+
+- Your profile, posts, and connections are hidden but fully preserved
+- No one can see your account while it is deactivated
+- You can reactivate at any time simply by logging back in
+
+
+
+
If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.
+
+
+
MPLS LLC · Sojorn · Minneapolis, MN
+
+
+`, toName)
+
+ textBody := fmt.Sprintf(`Account Deactivated
+
+Hey %s,
+
+Your Sojorn account has been deactivated. Your profile is now hidden from other users.
+
+What this means:
+- Your profile, posts, and connections are hidden but fully preserved
+- No one can see your account while it is deactivated
+- You can reactivate at any time simply by logging back in
+
+If you did not request this, please log back in immediately to reactivate your account and change your password.
+
+MPLS LLC - Sojorn - Minneapolis, MN`, toName)
+
+ return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
+}
+
+func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
+ subject := "Your Sojorn account is scheduled for deletion"
+
+ htmlBody := fmt.Sprintf(`
+
+
+
+
+
+
Account Deletion Scheduled
+
+
Hey %s,
+
+
Your Sojorn account has been scheduled for permanent deletion on %s.
+
+
+
What happens next:
+
+- Your account is immediately deactivated and hidden
+- On %s, all data will be permanently and irreversibly destroyed
+- This includes posts, messages, encryption keys, profile, followers, and your handle
+
+
+
+
+
Changed your mind?
+
Simply log back in before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.
+
+
+
If you did not request this, please log back in immediately to cancel the deletion and secure your account.
+
+
+
MPLS LLC · Sojorn · Minneapolis, MN
+
+
+`, toName, deletionDate, deletionDate, deletionDate)
+
+ textBody := fmt.Sprintf(`Account Deletion Scheduled
+
+Hey %s,
+
+Your Sojorn account has been scheduled for permanent deletion on %s.
+
+What happens next:
+- Your account is immediately deactivated and hidden
+- On %s, all data will be permanently and irreversibly destroyed
+- This includes posts, messages, encryption keys, profile, followers, and your handle
+
+Changed your mind?
+Simply log back in before %s to cancel the deletion and reactivate your account.
+
+If you did not request this, please log back in immediately to cancel the deletion and change your password.
+
+MPLS LLC - Sojorn - Minneapolis, MN`, toName, deletionDate, deletionDate, deletionDate)
+
+ return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
+}
+
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
diff --git a/sojorn_app/lib/main.dart b/sojorn_app/lib/main.dart
index b3413c7..50aa3a5 100644
--- a/sojorn_app/lib/main.dart
+++ b/sojorn_app/lib/main.dart
@@ -17,6 +17,10 @@ import 'package:google_fonts/google_fonts.dart';
import 'theme/app_theme.dart';
import 'providers/theme_provider.dart' as theme_provider;
import 'providers/auth_provider.dart';
+import 'providers/settings_provider.dart';
+import 'providers/feed_refresh_provider.dart';
+import 'providers/header_state_provider.dart';
+import 'services/chat_backup_manager.dart';
import 'routes/app_routes.dart';
Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
@@ -104,7 +108,7 @@ class _sojornAppState extends ConsumerState {
);
}
} else if (uri.host == 'verified') {
- ref.read(emailVerifiedEventProvider.notifier).state = true;
+ ref.read(emailVerifiedEventProvider.notifier).set(true);
if (_authService.isAuthenticated) {
_authService.refreshSession();
@@ -135,6 +139,11 @@ class _sojornAppState extends ConsumerState {
} else if (data.event == AuthChangeEvent.signedOut) {
_syncManager?.dispose();
_syncManager = null;
+ // Invalidate all user-specific providers to prevent data leaking between accounts
+ ref.invalidate(settingsProvider);
+ ref.invalidate(feedRefreshProvider);
+ ref.invalidate(headerControllerProvider);
+ ChatBackupManager.instance.reset();
}
});
}
diff --git a/sojorn_app/lib/providers/auth_provider.dart b/sojorn_app/lib/providers/auth_provider.dart
index c9534ee..bec020d 100644
--- a/sojorn_app/lib/providers/auth_provider.dart
+++ b/sojorn_app/lib/providers/auth_provider.dart
@@ -23,4 +23,13 @@ final isAuthenticatedProvider = Provider((ref) {
return authService.currentUser != null;
});
-final emailVerifiedEventProvider = StateProvider((ref) => false);
+class EmailVerifiedEventNotifier extends Notifier {
+ @override
+ bool build() => false;
+
+ void set(bool value) => state = value;
+}
+
+final emailVerifiedEventProvider =
+ NotifierProvider(
+ EmailVerifiedEventNotifier.new);
diff --git a/sojorn_app/lib/providers/feed_refresh_provider.dart b/sojorn_app/lib/providers/feed_refresh_provider.dart
index ac9c901..74c14f3 100644
--- a/sojorn_app/lib/providers/feed_refresh_provider.dart
+++ b/sojorn_app/lib/providers/feed_refresh_provider.dart
@@ -1,5 +1,11 @@
-// Direct import of the specific Riverpod classes
import 'package:flutter_riverpod/flutter_riverpod.dart';
-// Simple provider definition
-final feedRefreshProvider = StateProvider((ref) => 0);
+class FeedRefreshNotifier extends Notifier {
+ @override
+ int build() => 0;
+
+ void increment() => state++;
+}
+
+final feedRefreshProvider =
+ NotifierProvider(FeedRefreshNotifier.new);
diff --git a/sojorn_app/lib/providers/header_state_provider.dart b/sojorn_app/lib/providers/header_state_provider.dart
index 101f912..d2121d1 100644
--- a/sojorn_app/lib/providers/header_state_provider.dart
+++ b/sojorn_app/lib/providers/header_state_provider.dart
@@ -62,12 +62,12 @@ class HeaderState {
}
}
-class HeaderController extends ChangeNotifier {
- HeaderState _state = HeaderState.feed();
+class HeaderController extends Notifier {
VoidCallback? _feedRefresh;
List _feedTrailingActions = const [];
- HeaderState get state => _state;
+ @override
+ HeaderState build() => HeaderState.feed();
void configureFeed({
VoidCallback? onRefresh,
@@ -75,21 +75,19 @@ class HeaderController extends ChangeNotifier {
}) {
_feedRefresh = onRefresh;
_feedTrailingActions = trailingActions;
- if (_state.mode == HeaderMode.feed) {
- _state = HeaderState.feed(
+ if (state.mode == HeaderMode.feed) {
+ state = HeaderState.feed(
onRefresh: _feedRefresh,
trailingActions: _feedTrailingActions,
);
- notifyListeners();
}
}
void setFeed() {
- _state = HeaderState.feed(
+ state = HeaderState.feed(
onRefresh: _feedRefresh,
trailingActions: _feedTrailingActions,
);
- notifyListeners();
}
void setContext({
@@ -98,15 +96,14 @@ class HeaderController extends ChangeNotifier {
VoidCallback? onRefresh,
List trailingActions = const [],
}) {
- _state = HeaderState.context(
+ state = HeaderState.context(
title: title,
onBack: onBack,
onRefresh: onRefresh,
trailingActions: trailingActions,
);
- notifyListeners();
}
}
final headerControllerProvider =
- ChangeNotifierProvider((ref) => HeaderController());
+ NotifierProvider(HeaderController.new);
diff --git a/sojorn_app/lib/providers/quip_upload_provider.dart b/sojorn_app/lib/providers/quip_upload_provider.dart
index 2fe8634..bb4b409 100644
--- a/sojorn_app/lib/providers/quip_upload_provider.dart
+++ b/sojorn_app/lib/providers/quip_upload_provider.dart
@@ -113,7 +113,7 @@ class QuipUploadNotifier extends Notifier {
);
// Trigger feed refresh
- ref.read(feedRefreshProvider.notifier).state++;
+ ref.read(feedRefreshProvider.notifier).increment();
state = state.copyWith(
isUploading: false,
diff --git a/sojorn_app/lib/providers/settings_provider.dart b/sojorn_app/lib/providers/settings_provider.dart
index f2a878b..28cd7c0 100644
--- a/sojorn_app/lib/providers/settings_provider.dart
+++ b/sojorn_app/lib/providers/settings_provider.dart
@@ -41,13 +41,15 @@ class SettingsState {
}
}
-class SettingsNotifier extends StateNotifier {
- final ApiService _apiService;
-
- SettingsNotifier(this._apiService) : super(SettingsState()) {
- refresh();
+class SettingsNotifier extends Notifier {
+ @override
+ SettingsState build() {
+ Future.microtask(() => refresh());
+ return SettingsState();
}
+ ApiService get _apiService => ApiService.instance;
+
Future refresh() async {
state = state.copyWith(isLoading: true, error: null);
try {
@@ -116,6 +118,4 @@ class SettingsNotifier extends StateNotifier {
}
}
-final settingsProvider = StateNotifierProvider((ref) {
- return SettingsNotifier(ApiService.instance);
-});
+final settingsProvider = NotifierProvider(SettingsNotifier.new);
diff --git a/sojorn_app/lib/providers/upload_provider.dart b/sojorn_app/lib/providers/upload_provider.dart
index 4af783c..a351413 100644
--- a/sojorn_app/lib/providers/upload_provider.dart
+++ b/sojorn_app/lib/providers/upload_provider.dart
@@ -8,11 +8,12 @@ class UploadProgress {
UploadProgress({this.progress = 0, this.isUploading = false, this.error});
}
-class UploadNotifier extends StateNotifier {
- UploadNotifier() : super(UploadProgress());
+class UploadNotifier extends Notifier {
+ @override
+ UploadProgress build() => UploadProgress();
void setProgress(double progress) {
- state = UploadProgress(progress: progress, isUploading = true);
+ state = UploadProgress(progress: progress, isUploading: true);
}
void start() {
@@ -34,6 +35,4 @@ class UploadNotifier extends StateNotifier {
}
}
-final uploadProvider = StateNotifierProvider((ref) {
- return UploadNotifier();
-});
+final uploadProvider = NotifierProvider(UploadNotifier.new);
diff --git a/sojorn_app/lib/screens/auth/sign_in_screen.dart b/sojorn_app/lib/screens/auth/sign_in_screen.dart
index d0e42da..bdaa77d 100644
--- a/sojorn_app/lib/screens/auth/sign_in_screen.dart
+++ b/sojorn_app/lib/screens/auth/sign_in_screen.dart
@@ -169,10 +169,6 @@ class _SignInScreenState extends ConsumerState {
try {
final authenticated = await _localAuth.authenticate(
localizedReason: 'Unlock sojorn with biometrics',
- options: const AuthenticationOptions(
- biometricOnly: true,
- stickyAuth: true,
- ),
);
if (!authenticated) {
@@ -213,7 +209,7 @@ class _SignInScreenState extends ConsumerState {
behavior: SnackBarBehavior.floating,
),
);
- ref.read(emailVerifiedEventProvider.notifier).state = false;
+ ref.read(emailVerifiedEventProvider.notifier).set(false);
}
});
diff --git a/sojorn_app/lib/screens/compose/compose_screen.dart b/sojorn_app/lib/screens/compose/compose_screen.dart
index aa6703c..bde0f72 100644
--- a/sojorn_app/lib/screens/compose/compose_screen.dart
+++ b/sojorn_app/lib/screens/compose/compose_screen.dart
@@ -402,7 +402,7 @@ class _ComposeScreenState extends ConsumerState {
);
if (mounted) {
- ref.read(feedRefreshProvider.notifier).state++;
+ ref.read(feedRefreshProvider.notifier).increment();
Navigator.of(context).pop(true);
sojornSnackbar.showSuccess(
context: context,
diff --git a/sojorn_app/lib/screens/compose/image_editor_screen.dart b/sojorn_app/lib/screens/compose/image_editor_screen.dart
index cb13bc2..dabe989 100644
--- a/sojorn_app/lib/screens/compose/image_editor_screen.dart
+++ b/sojorn_app/lib/screens/compose/image_editor_screen.dart
@@ -67,136 +67,11 @@ class sojornImageEditor extends StatelessWidget {
}
ProImageEditorConfigs _buildConfigs() {
- // Determine aspect ratio based on post type
- final aspectRatios = _getAspectRatios();
-
return ProImageEditorConfigs(
theme: _buildEditorTheme(),
- imageEditorTheme: ImageEditorTheme(
- background: _matteBlack,
- appBarBackgroundColor: _matteBlack,
- appBarForegroundColor: Colors.white,
- bottomBarBackgroundColor: _matteBlack,
- cropRotateEditor: CropRotateEditorTheme(
- appBarBackgroundColor: _matteBlack,
- appBarForegroundColor: Colors.white,
- bottomBarBackgroundColor: _matteBlack,
- bottomBarForegroundColor: Colors.white,
- background: _matteBlack,
- cropCornerColor: AppTheme.brightNavy,
- helperLineColor: AppTheme.brightNavy,
- cropOverlayColor: Colors.black,
- aspectRatioSheetBackgroundColor: _panelBlack,
- aspectRatioSheetForegroundColor: Colors.white,
- ),
- filterEditor: FilterEditorTheme(
- appBarBackgroundColor: _matteBlack,
- appBarForegroundColor: Colors.white,
- background: _matteBlack,
- previewTextColor: Colors.white70,
- previewSelectedTextColor: AppTheme.brightNavy,
- ),
- tuneEditor: TuneEditorTheme(
- appBarBackgroundColor: _matteBlack,
- appBarForegroundColor: Colors.white,
- background: _matteBlack,
- bottomBarColor: _matteBlack,
- bottomBarActiveItemColor: AppTheme.brightNavy,
- bottomBarInactiveItemColor: Colors.white70,
- ),
- ),
- // Enable creative features for user expression
- paintEditorConfigs: const PaintEditorConfigs(
- enabled: true,
- ),
- textEditorConfigs: const TextEditorConfigs(
- enabled: true,
- ),
- emojiEditorConfigs: const EmojiEditorConfigs(
- enabled: true,
- ),
- stickerEditorConfigs: StickerEditorConfigs(
- enabled: true,
- buildStickers: (setLayer, scrollController) {
- // Return default stickers widget
- return const SizedBox.shrink();
- },
- ),
- blurEditorConfigs: const BlurEditorConfigs(enabled: false),
- imageGenerationConfigs: const ImageGenerationConfigs(
- outputFormat: OutputFormat.jpg,
- jpegQuality: 85,
- enableUseOriginalBytes: false,
- ),
- customWidgets: ImageEditorCustomWidgets(
- mainEditor: CustomWidgetsMainEditor(
- appBar: (editor, rebuildStream) => ReactiveCustomAppbar(
- stream: rebuildStream,
- builder: (context) {
- final canUndo = editor.canUndo;
- final canRedo = editor.canRedo;
-
- return AppBar(
- backgroundColor: _matteBlack,
- foregroundColor: Colors.white,
- leading: IconButton(
- tooltip: 'Cancel',
- icon: const Icon(Icons.close),
- onPressed: editor.closeEditor,
- ),
- actions: [
- IconButton(
- tooltip: 'Undo',
- onPressed: canUndo ? editor.undoAction : null,
- icon: Icon(
- Icons.undo,
- color: canUndo ? Colors.white : Colors.white38,
- ),
- ),
- IconButton(
- tooltip: 'Redo',
- onPressed: canRedo ? editor.redoAction : null,
- icon: Icon(
- Icons.redo,
- color: canRedo ? Colors.white : Colors.white38,
- ),
- ),
- TextButton(
- onPressed: editor.doneEditing,
- child: Text(isBeacon ? 'Save Beacon' : 'Save'),
- ),
- const SizedBox(width: 6),
- ],
- );
- },
- ),
- ),
- ),
);
}
- Map _getAspectRatios() {
- if (isBeacon) {
- // Beacon: Allow original and free ratios to preserve context
- return {
- 'options': const [
- 'original',
- 'free',
- '4:5',
- '16:9',
- ],
- };
- } else {
- // Main Feed: Lock to square for consistency
- return {
- 'options': const [
- '1:1',
- 'free',
- ],
- };
- }
- }
-
@override
Widget build(BuildContext context) {
if (imageBytes != null) {
diff --git a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart
index 6b73179..14473a8 100644
--- a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart
+++ b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart
@@ -66,7 +66,7 @@ class _FeedsojornScreenState extends ConsumerState {
try {
final apiService = ref.read(apiServiceProvider);
- final posts = await apiService.getsojornFeed(
+ final posts = await apiService.getSojornFeed(
limit: 20,
offset: refresh ? 0 : _posts.length,
);
diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
index 6915c9f..1611e67 100644
--- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
+++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart
@@ -52,6 +52,9 @@ class _ThreadedConversationScreenState extends ConsumerState {
const Text('Deactivate Account'),
],
),
- content: const Text(
- 'Your account will be hidden and you will be logged out. '
- 'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
- 'You can reactivate at any time simply by logging back in.',
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Your account will be hidden and you will be logged out. '
+ 'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
+ 'You can reactivate at any time simply by logging back in.',
+ ),
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.orange.withOpacity(0.1),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.orange.withOpacity(0.3)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.email_outlined, color: Colors.orange, size: 18),
+ const SizedBox(width: 8),
+ const Expanded(
+ child: Text(
+ 'A confirmation email will be sent to your registered address.',
+ style: TextStyle(fontSize: 13),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
),
actions: [
TextButton(
@@ -979,87 +1006,163 @@ class _ProfileSettingsScreenState extends ConsumerState {
}
void _showDeleteDialog() {
+ final confirmController = TextEditingController();
showDialog(
context: context,
- builder: (ctx) => AlertDialog(
- title: Row(
- children: [
- Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
- const SizedBox(width: 8),
- const Text('Delete Account'),
+ builder: (ctx) => StatefulBuilder(
+ builder: (ctx, setDialogState) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
+ const SizedBox(width: 8),
+ const Text('Delete Account'),
+ ],
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Your account will be deactivated immediately and permanently deleted after 14 days.\n\n'
+ 'During those 14 days, you can cancel the deletion by logging back in. '
+ 'After that, ALL data will be irreversibly destroyed.',
+ ),
+ const SizedBox(height: 16),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.red.withOpacity(0.08),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.red.withOpacity(0.3)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.email_outlined, color: Colors.red.shade400, size: 18),
+ const SizedBox(width: 8),
+ const Expanded(
+ child: Text(
+ 'An email will be sent confirming the scheduled deletion and grace period.',
+ style: TextStyle(fontSize: 13),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ const Text('Type DELETE to confirm:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
+ const SizedBox(height: 8),
+ TextField(
+ controller: confirmController,
+ onChanged: (_) => setDialogState(() {}),
+ decoration: const InputDecoration(
+ hintText: 'DELETE',
+ border: OutlineInputBorder(),
+ isDense: true,
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: confirmController.text == 'DELETE'
+ ? () async {
+ Navigator.pop(ctx);
+ await _performDeletion();
+ }
+ : null,
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('Delete My Account'),
+ ),
],
),
- content: const Text(
- 'Your account will be deactivated immediately and permanently deleted after 14 days.\n\n'
- 'During those 14 days, you can cancel the deletion by logging back in. '
- 'After that, ALL data will be irreversibly destroyed — posts, messages, encryption keys, profile, everything.\n\n'
- 'Are you sure you want to proceed?',
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () async {
- Navigator.pop(ctx);
- await _performDeletion();
- },
- style: TextButton.styleFrom(foregroundColor: Colors.red),
- child: const Text('Delete My Account'),
- ),
- ],
),
);
}
void _showSuperDeleteDialog() {
+ final confirmController = TextEditingController();
showDialog(
context: context,
- builder: (ctx) => AlertDialog(
- title: Row(
- children: [
- Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
- const SizedBox(width: 8),
- const Expanded(child: Text('Immediate Destroy', style: TextStyle(color: Colors.red))),
- ],
- ),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: const [
- Text(
- 'THIS IS IRREVERSIBLE.',
- style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
+ builder: (ctx) => StatefulBuilder(
+ builder: (ctx, setDialogState) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
+ const SizedBox(width: 8),
+ const Expanded(child: Text('Immediate Destroy', style: TextStyle(color: Colors.red))),
+ ],
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'THIS IS IRREVERSIBLE.',
+ style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red.shade800),
+ ),
+ const SizedBox(height: 12),
+ const Text(
+ 'A confirmation email will be sent to your registered address. '
+ 'When you click the link in that email, your account and ALL data '
+ 'will be permanently and immediately destroyed.',
+ ),
+ const SizedBox(height: 12),
+ Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Colors.red.shade900.withOpacity(0.15),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: Colors.red.shade800.withOpacity(0.4)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: const [
+ Text('What will be destroyed:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
+ SizedBox(height: 4),
+ Text('Posts, messages, encryption keys, profile, followers, handle — everything. '
+ 'There is NO recovery, NO backup, NO undo.',
+ style: TextStyle(fontSize: 13)),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ const Text('Type DESTROY to continue:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
+ const SizedBox(height: 8),
+ TextField(
+ controller: confirmController,
+ onChanged: (_) => setDialogState(() {}),
+ decoration: const InputDecoration(
+ hintText: 'DESTROY',
+ border: OutlineInputBorder(),
+ isDense: true,
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
),
- SizedBox(height: 12),
- Text(
- 'This will send a confirmation email to your registered address. '
- 'When you click the link in that email, your account and ALL associated data '
- 'will be permanently and immediately destroyed.\n\n'
- 'There is NO recovery. NO backup. NO undo.\n\n'
- 'Your posts, messages, encryption keys, profile, followers, and handle '
- 'will all be erased forever.',
+ TextButton(
+ onPressed: confirmController.text == 'DESTROY'
+ ? () async {
+ Navigator.pop(ctx);
+ await _performSuperDelete();
+ }
+ : null,
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.white,
+ backgroundColor: confirmController.text == 'DESTROY' ? Colors.red.shade800 : Colors.grey,
+ ),
+ child: const Text('Send Destroy Email'),
),
],
),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(ctx),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () async {
- Navigator.pop(ctx);
- await _performSuperDelete();
- },
- style: TextButton.styleFrom(
- foregroundColor: Colors.white,
- backgroundColor: Colors.red.shade800,
- ),
- child: const Text('Send Destroy Confirmation Email'),
- ),
- ],
),
);
}
@@ -1070,13 +1173,16 @@ class _ProfileSettingsScreenState extends ConsumerState {
await api.callGoApi('/account/deactivate', method: 'POST');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Account deactivated. Log back in anytime to reactivate.')),
+ const SnackBar(
+ content: Text('Account deactivated. A confirmation email has been sent. Log back in anytime to reactivate.'),
+ backgroundColor: Colors.orange,
+ ),
);
await _signOut();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Failed to deactivate: $e')),
+ SnackBar(content: Text('Failed to deactivate: $e'), backgroundColor: Colors.red),
);
}
}
@@ -1084,16 +1190,40 @@ class _ProfileSettingsScreenState extends ConsumerState {
Future _performDeletion() async {
try {
final api = ref.read(apiServiceProvider);
- await api.callGoApi('/account', method: 'DELETE');
+ final result = await api.callGoApi('/account', method: 'DELETE');
if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Account scheduled for deletion in 14 days. Log back in to cancel.')),
+ final deletionDate = result['deletion_date'] ?? '14 days';
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.email, color: Colors.red.shade400, size: 24),
+ const SizedBox(width: 8),
+ const Text('Deletion Scheduled'),
+ ],
+ ),
+ content: Text(
+ 'Your account is scheduled for permanent deletion on $deletionDate.\n\n'
+ 'A confirmation email has been sent to your registered address with all the details.\n\n'
+ 'To cancel, simply log back in before that date.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ _signOut();
+ },
+ child: const Text('OK'),
+ ),
+ ],
+ ),
);
- await _signOut();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Failed to schedule deletion: $e')),
+ SnackBar(content: Text('Failed to schedule deletion: $e'), backgroundColor: Colors.red),
);
}
}
@@ -1107,18 +1237,37 @@ class _ProfileSettingsScreenState extends ConsumerState {
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
- title: const Text('Confirmation Email Sent'),
- content: const Text(
- 'A confirmation email has been sent to your registered address.\n\n'
- 'You MUST click the link in that email to complete the destruction. '
- 'If you did not mean to do this, simply ignore the email — your account will not be affected.\n\n'
- 'The link expires in 1 hour.',
+ title: Row(
+ children: [
+ Icon(Icons.mark_email_read, color: Colors.red.shade800, size: 24),
+ const SizedBox(width: 8),
+ const Expanded(child: Text('Confirmation Email Sent')),
+ ],
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: const [
+ Text(
+ 'A confirmation email has been sent to your registered address.',
+ style: TextStyle(fontWeight: FontWeight.w600),
+ ),
+ SizedBox(height: 12),
+ Text(
+ 'You MUST click the link in that email to complete the destruction. '
+ 'Your account will be destroyed the instant you click that link.',
+ ),
+ SizedBox(height: 12),
+ Text(
+ 'If you did not mean to do this, simply ignore the email — your account will not be affected. '
+ 'The link expires in 1 hour.',
+ style: TextStyle(color: Colors.grey),
+ ),
+ ],
),
actions: [
TextButton(
- onPressed: () {
- Navigator.pop(ctx);
- },
+ onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
@@ -1127,7 +1276,7 @@ class _ProfileSettingsScreenState extends ConsumerState {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Failed to initiate destroy: $e')),
+ SnackBar(content: Text('Failed to initiate destroy: $e'), backgroundColor: Colors.red),
);
}
}
diff --git a/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart b/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart
new file mode 100644
index 0000000..14eceb0
--- /dev/null
+++ b/sojorn_app/lib/screens/secure_chat/chat_data_management_screen.dart
@@ -0,0 +1,983 @@
+import 'package:flutter/material.dart';
+import 'package:google_fonts/google_fonts.dart';
+import '../../services/chat_backup_manager.dart';
+import '../../services/local_key_backup_service.dart';
+import '../../services/simple_e2ee_service.dart';
+import '../../services/local_message_store.dart';
+import '../../theme/app_theme.dart';
+
+class ChatDataManagementScreen extends StatefulWidget {
+ const ChatDataManagementScreen({super.key});
+
+ @override
+ State createState() => _ChatDataManagementScreenState();
+}
+
+class _ChatDataManagementScreenState extends State {
+ final ChatBackupManager _backupManager = ChatBackupManager.instance;
+ final SimpleE2EEService _e2ee = SimpleE2EEService();
+
+ bool _isLoading = true;
+ bool _isBackupEnabled = false;
+ bool _hasPassword = false;
+ DateTime? _lastBackupAt;
+ int _localMessageCount = 0;
+ int _lastBackupMsgCount = 0;
+
+ bool _isBackingUp = false;
+ bool _isRestoring = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadStatus();
+ }
+
+ Future _loadStatus() async {
+ final enabled = await _backupManager.isEnabled;
+ final hasPw = await _backupManager.hasPassword;
+ final lastAt = await _backupManager.lastBackupAt;
+ final msgCount = await _backupManager.getLocalMessageCount();
+ final lastCount = await _backupManager.lastBackupMessageCount;
+
+ if (!mounted) return;
+ setState(() {
+ _isBackupEnabled = enabled;
+ _hasPassword = hasPw;
+ _lastBackupAt = lastAt;
+ _localMessageCount = msgCount;
+ _lastBackupMsgCount = lastCount;
+ _isLoading = false;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppTheme.scaffoldBg,
+ appBar: AppBar(
+ backgroundColor: AppTheme.scaffoldBg,
+ elevation: 0,
+ surfaceTintColor: Colors.transparent,
+ title: Text(
+ 'Chat Data & Backup',
+ style: GoogleFonts.literata(
+ fontWeight: FontWeight.w600,
+ color: AppTheme.navyBlue,
+ fontSize: 20,
+ ),
+ ),
+ ),
+ body: _isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : RefreshIndicator(
+ onRefresh: _loadStatus,
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _buildStatusCard(),
+ const SizedBox(height: 20),
+ if (!_isBackupEnabled) _buildSetupCard(),
+ if (_isBackupEnabled) ...[
+ _buildBackupActions(),
+ const SizedBox(height: 20),
+ ],
+ _buildRestoreCard(),
+ const SizedBox(height: 20),
+ _buildLocalFileSection(),
+ const SizedBox(height: 20),
+ _buildDataManagement(),
+ const SizedBox(height: 40),
+ ],
+ ),
+ ),
+ );
+ }
+
+ // ── Status Card ──────────────────────────────────────
+
+ Widget _buildStatusCard() {
+ final isHealthy = _isBackupEnabled && _hasPassword;
+ final icon = isHealthy ? Icons.cloud_done_rounded : Icons.cloud_off_rounded;
+ final color = isHealthy ? Colors.green : AppTheme.textDisabled;
+ final label = isHealthy ? 'Auto-Backup Active' : 'Backup Not Set Up';
+
+ return Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: isHealthy
+ ? [AppTheme.brightNavy.withOpacity(0.08), AppTheme.brightNavy.withOpacity(0.03)]
+ : [AppTheme.cardSurface, AppTheme.cardSurface],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: isHealthy ? AppTheme.brightNavy.withOpacity(0.2) : AppTheme.border,
+ ),
+ ),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: color.withOpacity(0.12),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(icon, color: color, size: 28),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: GoogleFonts.inter(
+ fontWeight: FontWeight.w600,
+ color: AppTheme.navyBlue,
+ fontSize: 16,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ isHealthy
+ ? 'Your messages are automatically encrypted and backed up.'
+ : 'Set up backup to protect your messages.',
+ style: GoogleFonts.inter(
+ color: AppTheme.textSecondary,
+ fontSize: 13,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ if (isHealthy) ...[
+ const SizedBox(height: 16),
+ const Divider(height: 1),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ _buildStat(
+ Icons.message_rounded,
+ '$_localMessageCount',
+ 'Local Messages',
+ ),
+ _buildStat(
+ Icons.access_time_rounded,
+ _lastBackupAt != null ? _formatTimeAgo(_lastBackupAt!) : 'Never',
+ 'Last Backup',
+ ),
+ _buildStat(
+ Icons.cloud_upload_rounded,
+ '$_lastBackupMsgCount',
+ 'Backed Up',
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStat(IconData icon, String value, String label) {
+ return Expanded(
+ child: Column(
+ children: [
+ Icon(icon, size: 18, color: AppTheme.brightNavy.withOpacity(0.6)),
+ const SizedBox(height: 4),
+ Text(
+ value,
+ style: GoogleFonts.inter(
+ fontWeight: FontWeight.w700,
+ color: AppTheme.navyBlue,
+ fontSize: 14,
+ ),
+ ),
+ Text(
+ label,
+ style: GoogleFonts.inter(
+ color: AppTheme.textSecondary,
+ fontSize: 11,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Setup Card (shown when backup is not enabled) ────
+
+ Widget _buildSetupCard() {
+ return _card(
+ icon: Icons.shield_rounded,
+ iconColor: AppTheme.brightNavy,
+ title: 'Enable Cloud Backup',
+ subtitle: 'Choose a password to encrypt your messages. '
+ 'They\'ll be automatically backed up as you chat. '
+ 'You\'ll need this password to restore on another device.',
+ child: Column(
+ children: [
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ onPressed: _showSetupDialog,
+ icon: const Icon(Icons.lock_outline),
+ label: const Text('Set Backup Password'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.brightNavy,
+ foregroundColor: Colors.white,
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Backup Actions (shown when backup is enabled) ────
+
+ Widget _buildBackupActions() {
+ return _card(
+ icon: Icons.cloud_sync_rounded,
+ iconColor: AppTheme.brightNavy,
+ title: 'Backup Controls',
+ subtitle: null,
+ child: Column(
+ children: [
+ const SizedBox(height: 8),
+ _actionTile(
+ icon: Icons.cloud_upload_rounded,
+ title: 'Backup Now',
+ subtitle: 'Force an immediate encrypted backup',
+ trailing: _isBackingUp
+ ? const SizedBox(
+ width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Icon(Icons.chevron_right, color: AppTheme.textDisabled),
+ onTap: _isBackingUp ? null : _forceBackup,
+ ),
+ const Divider(height: 1),
+ _actionTile(
+ icon: Icons.key_rounded,
+ title: 'Change Password',
+ subtitle: 'Update your backup encryption password',
+ trailing: Icon(Icons.chevron_right, color: AppTheme.textDisabled),
+ onTap: _showChangePasswordDialog,
+ ),
+ const Divider(height: 1),
+ _actionTile(
+ icon: Icons.cloud_off_rounded,
+ title: 'Disable Auto-Backup',
+ subtitle: 'Stop automatic cloud backups',
+ trailing: Icon(Icons.chevron_right, color: AppTheme.error),
+ onTap: _confirmDisableBackup,
+ isDestructive: true,
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Restore Card ─────────────────────────────────────
+
+ Widget _buildRestoreCard() {
+ return _card(
+ icon: Icons.restore_rounded,
+ iconColor: AppTheme.royalPurple,
+ title: 'Restore from Cloud',
+ subtitle: 'Recover your encrypted messages from a previous backup. '
+ 'You\'ll need your backup password.',
+ child: Column(
+ children: [
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: OutlinedButton.icon(
+ onPressed: _isRestoring ? null : _showRestoreDialog,
+ icon: _isRestoring
+ ? const SizedBox(
+ width: 16, height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.cloud_download_rounded),
+ label: Text(_isRestoring ? 'Restoring...' : 'Restore Backup'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppTheme.royalPurple,
+ side: BorderSide(color: AppTheme.royalPurple.withOpacity(0.4)),
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Local File Section ───────────────────────────────
+
+ Widget _buildLocalFileSection() {
+ return _card(
+ icon: Icons.save_alt_rounded,
+ iconColor: AppTheme.navyBlue,
+ title: 'Local File Backup',
+ subtitle: 'Export keys and messages to a password-protected file on your device.',
+ child: Column(
+ children: [
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _exportToFile,
+ icon: const Icon(Icons.file_download, size: 18),
+ label: const Text('Export'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppTheme.navyBlue,
+ side: BorderSide(color: AppTheme.border),
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(10),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: _importFromFile,
+ icon: const Icon(Icons.file_upload, size: 18),
+ label: const Text('Import'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppTheme.navyBlue,
+ side: BorderSide(color: AppTheme.border),
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(10),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Data Management ──────────────────────────────────
+
+ Widget _buildDataManagement() {
+ return _card(
+ icon: Icons.storage_rounded,
+ iconColor: AppTheme.error,
+ title: 'Data Management',
+ subtitle: null,
+ child: Column(
+ children: [
+ const SizedBox(height: 8),
+ _actionTile(
+ icon: Icons.delete_sweep_rounded,
+ title: 'Clear Local Messages',
+ subtitle: '$_localMessageCount messages stored locally',
+ trailing: Icon(Icons.chevron_right, color: AppTheme.error),
+ onTap: _confirmClearMessages,
+ isDestructive: true,
+ ),
+ const Divider(height: 1),
+ _actionTile(
+ icon: Icons.vpn_key_rounded,
+ title: 'Reset Encryption Keys',
+ subtitle: 'Generate new identity (breaks existing chats)',
+ trailing: Icon(Icons.chevron_right, color: AppTheme.error),
+ onTap: _confirmResetKeys,
+ isDestructive: true,
+ ),
+ ],
+ ),
+ );
+ }
+
+ // ── Shared Widgets ───────────────────────────────────
+
+ Widget _card({
+ required IconData icon,
+ required Color iconColor,
+ required String title,
+ String? subtitle,
+ required Widget child,
+ }) {
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: AppTheme.cardSurface,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: AppTheme.border),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(icon, color: iconColor, size: 22),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ title,
+ style: GoogleFonts.literata(
+ fontWeight: FontWeight.w600,
+ color: AppTheme.navyBlue,
+ fontSize: 17,
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (subtitle != null) ...[
+ const SizedBox(height: 8),
+ Text(
+ subtitle,
+ style: GoogleFonts.inter(
+ color: AppTheme.textSecondary,
+ fontSize: 13,
+ height: 1.4,
+ ),
+ ),
+ ],
+ child,
+ ],
+ ),
+ );
+ }
+
+ Widget _actionTile({
+ required IconData icon,
+ required String title,
+ required String subtitle,
+ required Widget trailing,
+ VoidCallback? onTap,
+ bool isDestructive = false,
+ }) {
+ final color = isDestructive ? AppTheme.error : AppTheme.navyBlue;
+ return InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Row(
+ children: [
+ Icon(icon, color: color.withOpacity(0.7), size: 20),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: GoogleFonts.inter(
+ fontWeight: FontWeight.w500,
+ color: color,
+ fontSize: 14,
+ ),
+ ),
+ Text(
+ subtitle,
+ style: GoogleFonts.inter(
+ color: AppTheme.textDisabled,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ trailing,
+ ],
+ ),
+ ),
+ );
+ }
+
+ // ── Dialogs & Actions ────────────────────────────────
+
+ Future _showSetupDialog() async {
+ final passwordController = TextEditingController();
+ final confirmController = TextEditingController();
+ bool obscure = true;
+
+ final password = await showDialog(
+ context: context,
+ builder: (ctx) => StatefulBuilder(
+ builder: (ctx, setDialogState) => AlertDialog(
+ title: Text(
+ 'Set Backup Password',
+ style: GoogleFonts.literata(fontWeight: FontWeight.w600),
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'This password encrypts your messages before they leave your device. '
+ 'We never see your password or your messages.',
+ style: GoogleFonts.inter(fontSize: 13, color: AppTheme.textSecondary, height: 1.4),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: passwordController,
+ obscureText: obscure,
+ decoration: InputDecoration(
+ labelText: 'Password',
+ border: const OutlineInputBorder(),
+ suffixIcon: IconButton(
+ icon: Icon(obscure ? Icons.visibility_off : Icons.visibility),
+ onPressed: () => setDialogState(() => obscure = !obscure),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: confirmController,
+ obscureText: obscure,
+ decoration: const InputDecoration(
+ labelText: 'Confirm Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: () {
+ if (passwordController.text.isEmpty) return;
+ if (passwordController.text.length < 6) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Password must be at least 6 characters')),
+ );
+ return;
+ }
+ if (passwordController.text != confirmController.text) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Passwords do not match')),
+ );
+ return;
+ }
+ Navigator.pop(ctx, passwordController.text);
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.brightNavy,
+ foregroundColor: Colors.white,
+ ),
+ child: const Text('Enable Backup'),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ if (password == null || password.isEmpty) return;
+
+ setState(() => _isBackingUp = true);
+ try {
+ await _backupManager.enable(password);
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Backup enabled! Your messages will be backed up automatically.'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Setup failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isBackingUp = false);
+ }
+ }
+
+ Future _forceBackup() async {
+ setState(() => _isBackingUp = true);
+ try {
+ await _backupManager.forceBackup();
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Backup complete!'), backgroundColor: Colors.green),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Backup failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isBackingUp = false);
+ }
+ }
+
+ Future _showChangePasswordDialog() async {
+ final controller = TextEditingController();
+ final confirmController = TextEditingController();
+
+ final password = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Change Backup Password'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Your backup will be re-encrypted with the new password.',
+ style: GoogleFonts.inter(fontSize: 13, color: AppTheme.textSecondary),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: controller,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'New Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ const SizedBox(height: 12),
+ TextField(
+ controller: confirmController,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Confirm New Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
+ ElevatedButton(
+ onPressed: () {
+ if (controller.text.length < 6) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Password must be at least 6 characters')),
+ );
+ return;
+ }
+ if (controller.text != confirmController.text) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Passwords do not match')),
+ );
+ return;
+ }
+ Navigator.pop(ctx, controller.text);
+ },
+ child: const Text('Update'),
+ ),
+ ],
+ ),
+ );
+
+ if (password == null) return;
+ setState(() => _isBackingUp = true);
+ try {
+ await _backupManager.changePassword(password);
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Password updated and backup re-encrypted.'), backgroundColor: Colors.green),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isBackingUp = false);
+ }
+ }
+
+ Future _confirmDisableBackup() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Disable Auto-Backup?'),
+ content: const Text(
+ 'Your existing cloud backups will remain but no new backups will be made. '
+ 'Your stored backup password will be removed from this device.',
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ style: TextButton.styleFrom(foregroundColor: AppTheme.error),
+ child: const Text('Disable'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return;
+ await _backupManager.disable();
+ await _loadStatus();
+ }
+
+ Future _showRestoreDialog() async {
+ final controller = TextEditingController();
+
+ final password = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Restore from Cloud'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Enter the backup password you used when setting up backup.',
+ style: GoogleFonts.inter(fontSize: 13, color: AppTheme.textSecondary),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: controller,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Backup Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
+ ElevatedButton(
+ onPressed: () => Navigator.pop(ctx, controller.text),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppTheme.royalPurple,
+ foregroundColor: Colors.white,
+ ),
+ child: const Text('Restore'),
+ ),
+ ],
+ ),
+ );
+
+ if (password == null || password.isEmpty) return;
+ setState(() => _isRestoring = true);
+ try {
+ final result = await _backupManager.restoreFromCloud(password);
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Restored ${result['restored_keys']} keys and ${result['restored_messages']} messages!'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Restore failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isRestoring = false);
+ }
+ }
+
+ Future _exportToFile() async {
+ final controller = TextEditingController();
+ final password = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Export Backup File'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Choose a password to protect the exported file.',
+ style: GoogleFonts.inter(fontSize: 13, color: AppTheme.textSecondary),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: controller,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'File Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
+ ElevatedButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('Export')),
+ ],
+ ),
+ );
+
+ if (password == null || password.isEmpty) return;
+ try {
+ final backup = await LocalKeyBackupService.createEncryptedBackup(
+ password: password,
+ e2eeService: _e2ee,
+ includeMessages: true,
+ includeKeys: true,
+ );
+ final path = await LocalKeyBackupService.saveBackupToDevice(backup);
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Exported to: $path'), backgroundColor: Colors.green),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Export failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ }
+ }
+
+ Future _importFromFile() async {
+ try {
+ final backup = await LocalKeyBackupService.loadBackupFromDevice();
+
+ if (!mounted) return;
+ final controller = TextEditingController();
+ final password = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Enter File Password'),
+ content: TextField(
+ controller: controller,
+ obscureText: true,
+ decoration: const InputDecoration(
+ labelText: 'Password',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
+ ElevatedButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('Import')),
+ ],
+ ),
+ );
+
+ if (password == null || password.isEmpty) return;
+ final result = await LocalKeyBackupService.restoreFromBackup(
+ backup: backup,
+ password: password,
+ e2eeService: _e2ee,
+ );
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Imported ${result['restored_keys']} keys and ${result['restored_messages']} messages!'),
+ backgroundColor: Colors.green,
+ ),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Import failed: $e'), backgroundColor: Colors.red),
+ );
+ }
+ }
+ }
+
+ Future _confirmClearMessages() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Clear Local Messages?'),
+ content: Text(
+ 'This will delete $_localMessageCount messages from this device. '
+ 'Cloud backups will not be affected.',
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ style: TextButton.styleFrom(foregroundColor: AppTheme.error),
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return;
+ await _backupManager.clearLocalMessages();
+ await _loadStatus();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Local messages cleared.')),
+ );
+ }
+ }
+
+ Future _confirmResetKeys() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Reset Encryption Keys?'),
+ content: const Text(
+ 'This generates a new encryption identity. All existing conversations will break '
+ 'and cannot be decrypted. Only do this if you cannot restore from a backup.',
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx, true),
+ style: TextButton.styleFrom(foregroundColor: AppTheme.error),
+ child: const Text('Reset Keys'),
+ ),
+ ],
+ ),
+ );
+
+ if (confirmed != true) return;
+ await _e2ee.forceResetBrokenKeys();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
+ );
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────
+
+ String _formatTimeAgo(DateTime dt) {
+ final diff = DateTime.now().difference(dt);
+ if (diff.inMinutes < 1) return 'Just now';
+ if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
+ if (diff.inHours < 24) return '${diff.inHours}h ago';
+ if (diff.inDays < 7) return '${diff.inDays}d ago';
+ return '${dt.day}/${dt.month}/${dt.year}';
+ }
+}
diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart
index 99616d4..28dedf9 100644
--- a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart
+++ b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart
@@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../models/secure_chat.dart';
import '../../services/secure_chat_service.dart';
-import '../../services/local_key_backup_service.dart';
+import 'chat_data_management_screen.dart';
import '../../theme/app_theme.dart';
import '../../widgets/media/signed_media_image.dart';
import '../home/full_screen_shell.dart';
@@ -118,12 +118,15 @@ class _SecureChatFullScreenState extends State {
return FullScreenShell(
title: Row(
children: [
- Text(
- 'Messages',
- style: GoogleFonts.literata(
- fontWeight: FontWeight.w600,
- color: AppTheme.navyBlue,
- fontSize: 20,
+ Flexible(
+ child: Text(
+ 'Messages',
+ style: GoogleFonts.literata(
+ fontWeight: FontWeight.w600,
+ color: AppTheme.navyBlue,
+ fontSize: 20,
+ ),
+ overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
@@ -216,7 +219,7 @@ class _SecureChatFullScreenState extends State {
Navigator.push(
context,
MaterialPageRoute(
- builder: (context) => const LocalBackupScreen(),
+ builder: (context) => const ChatDataManagementScreen(),
),
);
},
diff --git a/sojorn_app/lib/screens/settings/privacy_settings_screen.dart b/sojorn_app/lib/screens/settings/privacy_settings_screen.dart
index 7b2d9c9..1a42ee4 100644
--- a/sojorn_app/lib/screens/settings/privacy_settings_screen.dart
+++ b/sojorn_app/lib/screens/settings/privacy_settings_screen.dart
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../theme/app_theme.dart';
import '../../widgets/app_scaffold.dart';
-import '../../services/api_service.dart';
+import '../../providers/api_provider.dart';
import '../../models/profile_privacy_settings.dart';
/// Comprehensive privacy settings screen for managing account privacy
@@ -129,7 +129,10 @@ class _PrivacySettingsScreenState
},
child: AppScaffold(
title: 'Privacy Settings',
- showBackButton: true,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
actions: [
if (_hasChanges)
TextButton(
@@ -221,12 +224,12 @@ class _PrivacySettingsScreenState
children: [
Row(
children: [
- Icon(icon, size: 20, color: AppTheme.primaryColor),
+ Icon(icon, size: 20, color: AppTheme.brightNavy),
const SizedBox(width: 8),
Text(
title,
style: AppTheme.headlineSmall.copyWith(
- color: AppTheme.primaryColor,
+ color: AppTheme.brightNavy,
fontSize: 16,
),
),
@@ -235,7 +238,7 @@ class _PrivacySettingsScreenState
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
- color: AppTheme.surfaceColor,
+ color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -252,7 +255,7 @@ class _PrivacySettingsScreenState
subtitle: const Text('Only approved followers can see your posts'),
value: _settings.isPrivate,
onChanged: (value) => _updateSetting(() => _settings.isPrivate = value),
- activeColor: AppTheme.primaryColor,
+ activeColor: AppTheme.brightNavy,
);
}
@@ -271,7 +274,7 @@ class _PrivacySettingsScreenState
subtitle: const Text('Let others add to your posts'),
value: _settings.allowChains,
onChanged: (value) => _updateSetting(() => _settings.allowChains = value),
- activeColor: AppTheme.primaryColor,
+ activeColor: AppTheme.brightNavy,
);
}
@@ -308,7 +311,7 @@ class _PrivacySettingsScreenState
value: _settings.showActivityStatus,
onChanged: (value) =>
_updateSetting(() => _settings.showActivityStatus = value),
- activeColor: AppTheme.primaryColor,
+ activeColor: AppTheme.brightNavy,
);
}
@@ -319,7 +322,7 @@ class _PrivacySettingsScreenState
value: _settings.showInSearch,
onChanged: (value) =>
_updateSetting(() => _settings.showInSearch = value),
- activeColor: AppTheme.primaryColor,
+ activeColor: AppTheme.brightNavy,
);
}
@@ -330,15 +333,15 @@ class _PrivacySettingsScreenState
value: _settings.showInSuggestions,
onChanged: (value) =>
_updateSetting(() => _settings.showInSuggestions = value),
- activeColor: AppTheme.primaryColor,
+ activeColor: AppTheme.brightNavy,
);
}
Widget _buildCircleInfoTile() {
- return const ListTile(
+ return ListTile(
leading: Icon(Icons.info_outline, color: AppTheme.textSecondary),
- title: Text('About Circle'),
- subtitle: Text(
+ title: const Text('About Circle'),
+ subtitle: const Text(
'Share posts with only your closest friends. '
'Circle members see posts marked "Circle" visibility.',
),
@@ -413,7 +416,7 @@ class _PrivacySettingsScreenState
void _showVisibilityPicker() {
showModalBottomSheet(
context: context,
- backgroundColor: AppTheme.surfaceColor,
+ backgroundColor: AppTheme.cardSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -437,9 +440,9 @@ class _PrivacySettingsScreenState
Widget _buildVisibilityOption(String value, String label, IconData icon) {
final isSelected = _settings.defaultVisibility == value;
return ListTile(
- leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
+ leading: Icon(icon, color: isSelected ? AppTheme.brightNavy : null),
title: Text(label),
- trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
+ trailing: isSelected ? Icon(Icons.check, color: AppTheme.brightNavy) : null,
onTap: () {
_updateSetting(() => _settings.defaultVisibility = value);
Navigator.pop(context);
@@ -454,7 +457,7 @@ class _PrivacySettingsScreenState
}) {
showModalBottomSheet(
context: context,
- backgroundColor: AppTheme.surfaceColor,
+ backgroundColor: AppTheme.cardSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -485,9 +488,9 @@ class _PrivacySettingsScreenState
) {
final isSelected = currentValue == value;
return ListTile(
- leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
+ leading: Icon(icon, color: isSelected ? AppTheme.brightNavy : null),
title: Text(label),
- trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
+ trailing: isSelected ? Icon(Icons.check, color: AppTheme.brightNavy) : null,
onTap: () {
onChanged(value);
Navigator.pop(context);
@@ -532,7 +535,7 @@ class _PrivacySettingsScreenState
Text('• ${data['posts']?.length ?? 0} posts', style: AppTheme.bodyMedium),
Text('• ${data['following']?.length ?? 0} connections', style: AppTheme.bodyMedium),
const SizedBox(height: 12),
- const Text(
+ Text(
'The data has been prepared. In a production app, '
'this would be saved to your device.',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
diff --git a/sojorn_app/lib/services/ad_integration_service.dart b/sojorn_app/lib/services/ad_integration_service.dart
index 4866c91..64b5c5f 100644
--- a/sojorn_app/lib/services/ad_integration_service.dart
+++ b/sojorn_app/lib/services/ad_integration_service.dart
@@ -35,7 +35,6 @@ class AdIntegrationService {
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
return _currentAd;
}
- 'AdIntegrationService: no sponsored post available for $categoryId');
return null;
} catch (e) {
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
diff --git a/sojorn_app/lib/services/chat_backup_manager.dart b/sojorn_app/lib/services/chat_backup_manager.dart
new file mode 100644
index 0000000..1809d2f
--- /dev/null
+++ b/sojorn_app/lib/services/chat_backup_manager.dart
@@ -0,0 +1,212 @@
+import 'dart:async';
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'simple_e2ee_service.dart';
+import 'local_key_backup_service.dart';
+import 'local_message_store.dart';
+import 'auth_service.dart';
+
+/// Manages automatic encrypted cloud backups of chat data.
+///
+/// Flow: User enables backup → sets password once → chats auto-backup.
+/// To restore on another device, user enters the same password.
+class ChatBackupManager {
+ static final ChatBackupManager _instance = ChatBackupManager._internal();
+ static ChatBackupManager get instance => _instance;
+
+ ChatBackupManager._internal();
+
+ static const String _passwordKey = 'chat_backup_password_v1';
+ static const String _enabledKey = 'chat_backup_enabled';
+ static const String _lastBackupKey = 'chat_backup_last_at';
+ static const String _lastBackupCountKey = 'chat_backup_last_msg_count';
+ static const Duration _minBackupInterval = Duration(minutes: 10);
+ static const int _minNewMessages = 1;
+
+ final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
+ final SimpleE2EEService _e2ee = SimpleE2EEService();
+
+ Timer? _debounceTimer;
+ bool _backupInProgress = false;
+ DateTime? _lastBackupAt;
+ int _lastBackupMessageCount = 0;
+ bool? _enabledCache;
+
+ /// Whether backup has been set up (password stored + enabled)
+ Future get isEnabled async {
+ if (_enabledCache != null) return _enabledCache!;
+ final prefs = await SharedPreferences.getInstance();
+ _enabledCache = prefs.getBool(_enabledKey) ?? false;
+ return _enabledCache!;
+ }
+
+ /// Whether a backup password has been configured
+ Future get hasPassword async {
+ final pw = await _secureStorage.read(key: _passwordKey);
+ return pw != null && pw.isNotEmpty;
+ }
+
+ /// Last backup timestamp
+ Future get lastBackupAt async {
+ if (_lastBackupAt != null) return _lastBackupAt;
+ final prefs = await SharedPreferences.getInstance();
+ final ms = prefs.getInt(_lastBackupKey);
+ if (ms != null) {
+ _lastBackupAt = DateTime.fromMillisecondsSinceEpoch(ms);
+ }
+ return _lastBackupAt;
+ }
+
+ /// Number of messages in last backup
+ Future get lastBackupMessageCount async {
+ if (_lastBackupMessageCount > 0) return _lastBackupMessageCount;
+ final prefs = await SharedPreferences.getInstance();
+ _lastBackupMessageCount = prefs.getInt(_lastBackupCountKey) ?? 0;
+ return _lastBackupMessageCount;
+ }
+
+ /// Set up backup: store password and enable auto-backup.
+ /// Called once during the setup flow.
+ Future enable(String password) async {
+ await _secureStorage.write(key: _passwordKey, value: password);
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_enabledKey, true);
+ _enabledCache = true;
+
+ // Perform initial backup immediately
+ await _performBackup();
+ }
+
+ /// Disable auto-backup and clear stored password.
+ Future disable() async {
+ await _secureStorage.delete(key: _passwordKey);
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_enabledKey, false);
+ _enabledCache = false;
+ _debounceTimer?.cancel();
+ }
+
+ /// Change the backup password. Re-encrypts and uploads immediately.
+ Future changePassword(String newPassword) async {
+ await _secureStorage.write(key: _passwordKey, value: newPassword);
+ await _performBackup();
+ }
+
+ /// Schedule a backup after a debounce period.
+ /// Call this after sending/receiving messages.
+ void scheduleBackup() {
+ _debounceTimer?.cancel();
+ _debounceTimer = Timer(const Duration(seconds: 30), () {
+ _tryAutoBackup();
+ });
+ }
+
+ /// Trigger an immediate backup if conditions are met.
+ /// Called by SyncManager after sync, or on app background.
+ Future triggerBackupIfNeeded() async {
+ await _tryAutoBackup();
+ }
+
+ /// Force an immediate backup regardless of schedule.
+ Future forceBackup() async {
+ await _performBackup();
+ }
+
+ /// Restore from cloud backup using the given password.
+ /// Returns restore result with counts.
+ Future