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:

+ +
+ +

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:

+ +
+ +
+

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> restoreFromCloud(String password) async { + return await LocalKeyBackupService.restoreFromCloud( + password: password, + e2eeService: _e2ee, + ); + } + + /// Get current local message count for status display. + Future getLocalMessageCount() async { + final records = await LocalMessageStore.instance.getAllMessageRecords(); + return records.length; + } + + /// Clear all local message data. + Future clearLocalMessages() async { + await LocalMessageStore.instance.clearAll(); + } + + /// Reset everything on sign-out. + void reset() { + _debounceTimer?.cancel(); + _lastBackupAt = null; + _lastBackupMessageCount = 0; + _enabledCache = null; + _backupInProgress = false; + } + + // --- Private --- + + Future _tryAutoBackup() async { + if (_backupInProgress) return; + if (!AuthService.instance.isAuthenticated) return; + + final enabled = await isEnabled; + if (!enabled) return; + + final hasPw = await hasPassword; + if (!hasPw) return; + + // Check minimum interval + final last = await lastBackupAt; + if (last != null && DateTime.now().difference(last) < _minBackupInterval) { + return; + } + + // Check if there are new messages since last backup + final currentCount = await getLocalMessageCount(); + final lastCount = await lastBackupMessageCount; + if (currentCount <= 0) return; + if (currentCount - lastCount < _minNewMessages && last != null) return; + + await _performBackup(); + } + + Future _performBackup() async { + if (_backupInProgress) return; + _backupInProgress = true; + + try { + final password = await _secureStorage.read(key: _passwordKey); + if (password == null || password.isEmpty) return; + + if (!_e2ee.isReady) { + await _e2ee.initialize(); + if (!_e2ee.isReady) return; + } + + final backup = await LocalKeyBackupService.createEncryptedBackup( + password: password, + e2eeService: _e2ee, + includeMessages: true, + includeKeys: true, + ); + + await LocalKeyBackupService.uploadToCloud(backup: backup); + + // Record success + final now = DateTime.now(); + _lastBackupAt = now; + final msgCount = await getLocalMessageCount(); + _lastBackupMessageCount = msgCount; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_lastBackupKey, now.millisecondsSinceEpoch); + await prefs.setInt(_lastBackupCountKey, msgCount); + + debugPrint('[ChatBackup] Auto-backup complete: $msgCount messages'); + } catch (e) { + debugPrint('[ChatBackup] Auto-backup failed: $e'); + } finally { + _backupInProgress = false; + } + } +} diff --git a/sojorn_app/lib/services/sync_manager.dart b/sojorn_app/lib/services/sync_manager.dart index e780ac2..b8c7a3b 100644 --- a/sojorn_app/lib/services/sync_manager.dart +++ b/sojorn_app/lib/services/sync_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'auth_service.dart'; +import 'chat_backup_manager.dart'; import 'secure_chat_service.dart'; import 'simple_e2ee_service.dart'; import 'local_message_store.dart'; @@ -60,8 +61,11 @@ class SyncManager with WidgetsBindingObserver { _startTimer(); _syncIfStale(); } - } else if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive || + } else if (state == AppLifecycleState.paused) { + _stopTimer(); + // Trigger backup when app goes to background + ChatBackupManager.instance.triggerBackupIfNeeded(); + } else if (state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { _stopTimer(); } @@ -114,6 +118,9 @@ class SyncManager with WidgetsBindingObserver { await _secureChatService.syncAllConversations(force: true); _lastSyncAt = DateTime.now(); + + // Schedule auto-backup after successful sync + ChatBackupManager.instance.scheduleBackup(); } catch (e) { } finally { _syncInProgress = false; diff --git a/sojorn_app/lib/widgets/post/post_menu.dart b/sojorn_app/lib/widgets/post/post_menu.dart index 5b608a3..b86d390 100644 --- a/sojorn_app/lib/widgets/post/post_menu.dart +++ b/sojorn_app/lib/widgets/post/post_menu.dart @@ -105,7 +105,7 @@ class _PostMenuState extends ConsumerState { ); // Refresh feed to show updated post - ref.read(feedRefreshProvider.notifier).state++; + ref.read(feedRefreshProvider.notifier).increment(); widget.onPostDeleted?.call(); } catch (e) { sojornSnackbar.showError( @@ -152,7 +152,7 @@ class _PostMenuState extends ConsumerState { ); // Refresh feed to remove deleted post immediately - ref.read(feedRefreshProvider.notifier).state++; + ref.read(feedRefreshProvider.notifier).increment(); widget.onPostDeleted?.call(); } catch (e) { sojornSnackbar.showError( @@ -228,7 +228,7 @@ class _PostMenuState extends ConsumerState { ); // Refresh feed to show privacy changes - ref.read(feedRefreshProvider.notifier).state++; + ref.read(feedRefreshProvider.notifier).increment(); widget.onPostDeleted?.call(); } catch (e) { sojornSnackbar.showError( @@ -261,7 +261,7 @@ class _PostMenuState extends ConsumerState { } // Refresh feed to show pin state changes - ref.read(feedRefreshProvider.notifier).state++; + ref.read(feedRefreshProvider.notifier).increment(); widget.onPostDeleted?.call(); } catch (e) { sojornSnackbar.showError( diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index 8f4e469..6bcb9ab 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -95,8 +95,10 @@ class _sojornPostCardState extends ConsumerState { bool get _shouldBlurNsfw { if (!post.isNsfw || _nsfwRevealed) return false; final settings = ref.read(settingsProvider); - final blurEnabled = settings.user?.nsfwBlurEnabled ?? true; - return blurEnabled; + // Always blur if user hasn't opted into NSFW content + if (!(settings.user?.nsfwEnabled ?? false)) return true; + // If opted in, respect the blur toggle + return settings.user?.nsfwBlurEnabled ?? true; } double get _avatarSize { diff --git a/sojorn_app/pubspec.lock b/sojorn_app/pubspec.lock index dc3b891..56b0ef3 100644 --- a/sojorn_app/pubspec.lock +++ b/sojorn_app/pubspec.lock @@ -1,22 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 url: "https://pub.dev" source: hosted - version: "1.3.59" + version: "1.3.66" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" app_links: dependency: "direct main" description: name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.0.0" app_links_linux: dependency: transitive description: @@ -101,26 +117,26 @@ packages: dependency: "direct main" description: name: camera - sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8 + sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab url: "https://pub.dev" source: hosted - version: "0.10.6" - camera_android: + version: "0.11.3" + camera_android_camerax: dependency: transitive description: - name: camera_android - sha256: "50c0d1c4b122163e3d7cdfcd6d4cd8078aac27d0f1cd1e7b3fa69e6b3f06f4b7" + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" url: "https://pub.dev" source: hosted - version: "0.10.10+14" + version: "0.6.30" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: a600b60a7752cc5fa9de476cd0055539d7a3b9d62662f4f446bae49eba2267df + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" url: "https://pub.dev" source: hosted - version: "0.9.22+9" + version: "0.9.23+2" camera_platform_interface: dependency: transitive description: @@ -169,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.13.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -217,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" cross_file: dependency: transitive description: @@ -285,18 +317,18 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" device_info_plus: - dependency: transitive + dependency: "direct main" description: name: device_info_plus - sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "11.5.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: @@ -329,14 +361,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - emoji_picker_flutter: - dependency: transitive - description: - name: emoji_picker_flutter - sha256: "08567e6f914d36c32091a96cf2f51d2558c47aa2bd47a590dc4f50e42e0965f6" - url: "https://pub.dev" - source: hosted - version: "3.1.0" fake_async: dependency: transitive description: @@ -421,10 +445,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" url: "https://pub.dev" source: hosted - version: "3.15.2" + version: "4.4.0" firebase_core_platform_interface: dependency: transitive description: @@ -437,34 +461,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" url: "https://pub.dev" source: hosted - version: "2.24.1" + version: "3.4.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" url: "https://pub.dev" source: hosted - version: "15.2.10" + version: "16.1.1" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" url: "https://pub.dev" source: hosted - version: "4.6.10" + version: "4.7.6" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" url: "https://pub.dev" source: hosted - version: "3.10.10" + version: "4.1.2" fixnum: dependency: transitive description: @@ -554,66 +578,74 @@ packages: dependency: transitive description: name: flutter_inappwebview - sha256: a8f5c9dd300a8cc7fde7bb902ae57febe95e9269424e4d08d5a1a56214e1e6ff + sha256: "3952d116ee93bad2946401377e7ade87b5ef200e95ecb5ba1affa1b6329a6867" url: "https://pub.dev" source: hosted - version: "6.2.0-beta.2" + version: "6.2.0-beta.3" flutter_inappwebview_android: dependency: transitive description: name: flutter_inappwebview_android - sha256: "2427e89d9c7b00cc756f800932d7ab8f3272d3fbc71544e1aedb3dbc17dae074" + sha256: "8dfb76bd4e507112c3942c2272eeb01fab2e42be11374e5eb226f58698e7a04b" url: "https://pub.dev" source: hosted - version: "1.2.0-beta.2" + version: "1.2.0-beta.3" flutter_inappwebview_internal_annotations: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" flutter_inappwebview_ios: dependency: transitive description: name: flutter_inappwebview_ios - sha256: "7ff65d7408e453f9a4ff38f74673aeec8cae824cba8276b4b77350262bfe356a" + sha256: ae8a78829398771be863aa3c8804a9d40728e1815e66c9c966f86d2cc3ae4fd9 url: "https://pub.dev" source: hosted - version: "1.2.0-beta.2" + version: "1.2.0-beta.3" + flutter_inappwebview_linux: + dependency: transitive + description: + name: flutter_inappwebview_linux + sha256: "2e1a3b09bb911fb5a8bb155cb7f1eb1428a19b6e20363b9db48beef428b8cef5" + url: "https://pub.dev" + source: hosted + version: "0.1.0-beta.1" flutter_inappwebview_macos: dependency: transitive description: name: flutter_inappwebview_macos - sha256: be8b8ab0100c94ec9fc079a4d48b2bc8dd1a8b4c2647da34f1d3dae93cd5f88a + sha256: "545148cb5c46475ce669ab21621e9f2ad66e05f8e80b2cf49d4018879ab52393" url: "https://pub.dev" source: hosted - version: "1.2.0-beta.2" + version: "1.2.0-beta.3" flutter_inappwebview_platform_interface: dependency: transitive description: name: flutter_inappwebview_platform_interface - sha256: "2c99bf767900ba029d825bc6f494d30169ee83cdaa038d86e85fe70571d0a655" + sha256: e3522c76e6760d1c0a9ff690e30e1503f226783d3277fa4d26675911977e9766 url: "https://pub.dev" source: hosted - version: "1.4.0-beta.2" + version: "1.4.0-beta.3" flutter_inappwebview_web: dependency: transitive description: name: flutter_inappwebview_web - sha256: "6c4bb61ea9d52e51d79ea23da27c928d0430873c04ad380df39c1ef442b11f4e" + sha256: e98b8875ccb6a3fd255873318db45c18ab135ed0ed22d20169abad9f5c810eb9 url: "https://pub.dev" source: hosted - version: "1.2.0-beta.2" + version: "1.2.0-beta.3" flutter_inappwebview_windows: dependency: transitive description: name: flutter_inappwebview_windows - sha256: "0ff241f814b7caff63b9632cf858b6d3d9c35758040620a9745e5f6e9dd94d74" + sha256: "902edd6f6326952af822e21aa928f7426d723d45c94c15e6ce3c2d5640d28ad7" url: "https://pub.dev" source: hosted - version: "0.7.0-beta.2" + version: "0.7.0-beta.3" flutter_keyboard_visibility_linux: dependency: transitive description: @@ -658,10 +690,10 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -727,58 +759,58 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + sha256: e2026c72738a925a60db30258ff1f29974e40716749f3c9850aabf34ffc1a14c url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.4" + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_shaders: dependency: transitive description: @@ -805,22 +837,38 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" geolocator: dependency: "direct main" description: name: geolocator - sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27" + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" url: "https://pub.dev" source: hosted - version: "11.1.0" + version: "14.0.2" geolocator_android: dependency: transitive description: name: geolocator_android - sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" url: "https://pub.dev" source: hosted - version: "4.6.2" + version: "5.0.2" geolocator_apple: dependency: transitive description: @@ -829,6 +877,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -841,10 +897,10 @@ packages: dependency: transitive description: name: geolocator_web - sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.1.3" geolocator_windows: dependency: transitive description: @@ -865,18 +921,26 @@ packages: dependency: "direct main" description: name: go_router - sha256: b453934c36e289cef06525734d1e676c1f91da9e22e2017d9dcab6ce0f999175 + sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" url: "https://pub.dev" source: hosted - version: "15.1.3" + version: "17.1.0" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: c30eef5e7cd26eb89cc8065b4390ac86ce579f2fcdbe35220891c6278b5460da url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "8.0.1" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" gtk: dependency: transitive description: @@ -905,10 +969,10 @@ packages: dependency: transitive description: name: hooks - sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" html: dependency: transitive description: @@ -925,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: "direct main" description: @@ -1013,14 +1085,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: dependency: transitive description: @@ -1081,26 +1161,26 @@ packages: dependency: "direct main" description: name: local_auth - sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + sha256: a4f1bf57f0236a4aeb5e8f0ec180e197f4b112a3456baa6c1e73b546630b0422 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "3.0.0" local_auth_android: dependency: transitive description: name: local_auth_android - sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + sha256: "162b8e177fd9978c4620da2a8002a5c6bed4d20f0c6daf5137e72e9a8b767d2e" url: "https://pub.dev" source: hosted - version: "1.0.56" + version: "2.0.4" local_auth_darwin: dependency: transitive description: name: local_auth_darwin - sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + sha256: "668ea65edaab17380956e9713f57e34f78ede505ca0cfd8d39db34e2f260bfee" url: "https://pub.dev" source: hosted - version: "1.6.1" + version: "2.0.1" local_auth_platform_interface: dependency: transitive description: @@ -1113,10 +1193,10 @@ packages: dependency: transitive description: name: local_auth_windows - sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + sha256: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16 url: "https://pub.dev" source: hosted - version: "1.0.11" + version: "2.0.1" logger: dependency: transitive description: @@ -1197,14 +1277,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" objective_c: dependency: transitive description: name: objective_c - sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" url: "https://pub.dev" source: hosted - version: "9.2.5" + version: "9.3.0" octo_image: dependency: transitive description: @@ -1213,6 +1301,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: transitive description: @@ -1297,18 +1393,18 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 url: "https://pub.dev" source: hosted - version: "11.4.0" + version: "12.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "13.0.1" permission_handler_apple: dependency: transitive description: @@ -1377,10 +1473,18 @@ packages: dependency: "direct main" description: name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "4.0.0" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -1393,18 +1497,18 @@ packages: dependency: "direct main" description: name: pro_image_editor - sha256: "27190b0333af71e9949f366ac303496511ef6d67607f6f9797c9f136371a321f" + sha256: "715be7071919fa40ef47410ab44a4eecbd2080fc196bad3aec270965d2c84b16" url: "https://pub.dev" source: hosted - version: "6.2.3" + version: "11.22.1" pro_video_editor: dependency: "direct main" description: name: pro_video_editor - sha256: "18f62235212ff779a2ca967df4ce06cac22b7ff45051f46519754d94db2b04ff" + sha256: c88186f69cf0649f19ec20ad3a60c89ac98f940df43eed7b7601195bdcd0246b url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.5.0" proj4dart: dependency: transitive description: @@ -1497,10 +1601,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" rxdart: dependency: transitive description: @@ -1581,19 +1685,67 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqflite: dependency: transitive description: @@ -1690,6 +1842,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" test_api: dependency: transitive description: @@ -1698,6 +1858,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" timeago: dependency: "direct main" description: @@ -1850,22 +2018,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - vibration: - dependency: transitive - description: - name: vibration - sha256: "3b08a0579c2f9c18d5d78cb5c74f1005f731e02eeca6d72561a2e8059bf98ec3" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - vibration_platform_interface: - dependency: transitive - description: - name: vibration_platform_interface - sha256: "6ffeee63547562a6fef53c05a41d4fdcae2c0595b83ef59a4813b0612cd2bc36" - url: "https://pub.dev" - source: hosted - version: "0.0.3" video_editor: dependency: "direct main" description: @@ -1894,10 +2046,10 @@ packages: dependency: transitive description: name: video_player_avfoundation - sha256: "7cc0a9257103851eb299a2407e895b0fd6832d323dcfde622a23cdc25a1de269" + sha256: f46e9e20f1fe429760cf4dc118761336320d1bec0f50d255930c2355f2defb5b url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.9.1" video_player_platform_interface: dependency: transitive description: @@ -1954,6 +2106,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -1978,6 +2138,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: diff --git a/sojorn_app/pubspec.yaml b/sojorn_app/pubspec.yaml index 629e8e6..7f8983a 100644 --- a/sojorn_app/pubspec.yaml +++ b/sojorn_app/pubspec.yaml @@ -12,11 +12,11 @@ dependencies: sdk: flutter # Backend Services (Fully migrated to Go API) - firebase_core: ^3.4.0 - firebase_messaging: ^15.1.0 + firebase_core: ^4.4.0 + firebase_messaging: ^16.1.1 # State Management - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^3.2.1 # HTTP & API http: ^1.2.2 @@ -24,8 +24,8 @@ dependencies: # UI & Utilities cupertino_icons: ^1.0.8 - google_fonts: ^6.2.1 - share_plus: ^10.0.2 + google_fonts: ^8.0.1 + share_plus: ^10.1.4 timeago: ^3.7.0 url_launcher: ^6.3.2 image_picker: ^1.1.2 @@ -48,12 +48,12 @@ dependencies: markdown: ^7.3.0 # Image Editing - pro_image_editor: ^6.0.0 + pro_image_editor: ^11.22.1 pro_video_editor: ^1.3.0 - camera: ^0.10.0+1 + camera: ^0.11.3 # Navigation - go_router: ^15.1.0 + go_router: ^17.1.0 # Storage shared_preferences: ^2.3.4 @@ -63,32 +63,33 @@ dependencies: # E2EE Cryptography cryptography: ^2.5.0 convert: ^3.1.1 - pointycastle: ^3.7.3 + pointycastle: ^4.0.0 file_picker: ^10.3.10 universal_html: ^2.0.8 # Maps and Location flutter_map: ^8.2.0 latlong2: ^0.9.1 - geolocator: ^11.0.0 - permission_handler: ^11.3.0 - flutter_secure_storage: ^9.0.0 - local_auth: ^2.2.0 + geolocator: ^14.0.2 + permission_handler: ^12.0.1 + flutter_secure_storage: ^10.0.0 + local_auth: ^3.0.0 http_parser: ^4.1.2 - app_links: ^6.3.2 + app_links: ^7.0.0 cloudflare_turnstile: ^3.6.2 path_provider: ^2.1.5 video_editor: ^3.0.0 chewie: ^1.10.0 intl: 0.19.0 web_socket_channel: ^3.0.3 + device_info_plus: ^12.3.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.4 flutter: uses-material-design: true diff --git a/sojorn_app/windows/flutter/generated_plugin_registrant.cc b/sojorn_app/windows/flutter/generated_plugin_registrant.cc index 3c1e731..4a7cda6 100644 --- a/sojorn_app/windows/flutter/generated_plugin_registrant.cc +++ b/sojorn_app/windows/flutter/generated_plugin_registrant.cc @@ -7,7 +7,6 @@ #include "generated_plugin_registrant.h" #include -#include #include #include #include @@ -22,8 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); - EmojiPickerFlutterPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/sojorn_app/windows/flutter/generated_plugins.cmake b/sojorn_app/windows/flutter/generated_plugins.cmake index 63296ae..56056e3 100644 --- a/sojorn_app/windows/flutter/generated_plugins.cmake +++ b/sojorn_app/windows/flutter/generated_plugins.cmake @@ -4,7 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links - emoji_picker_flutter file_selector_windows firebase_core flutter_inappwebview_windows diff --git a/sojorn_docs/TODO.md b/sojorn_docs/TODO.md index 13477fe..5dad75a 100644 --- a/sojorn_docs/TODO.md +++ b/sojorn_docs/TODO.md @@ -1,6 +1,6 @@ # Sojorn Development TODO -**Last Updated**: February 6, 2026 +**Last Updated**: February 7, 2026 --- @@ -221,6 +221,23 @@ Small scattered items across the codebase: - ✅ GeoIP for location features - ✅ Automated deploy scripts +### NSFW Moderation System (Feb 7, 2026) +- ✅ AI moderation prompt: Cinemax nudity rule, violence 1-10 scale (≤5 allowed) +- ✅ Three-outcome moderation: clean / nsfw (auto-label + warn) / flag (remove + appeal email) +- ✅ DB: `nsfw_blur_enabled` column on `user_settings` +- ✅ Backend: `is_nsfw`/`nsfw_reason` returned from ALL post queries (feed, profile, detail, saved, liked, chain, focus context) +- ✅ Backend: NSFW posts excluded from search, discover, trending, hashtag pages +- ✅ Backend: NSFW in feed limited to own posts + followed users only +- ✅ Backend: `nsfw_warning` and `content_removed` notification types + appeal email +- ✅ Backend: user self-labeling (`is_nsfw` in CreatePost) +- ✅ Flutter: NSFW opt-in toggle + blur toggle in settings (hidden behind expandable at bottom) +- ✅ Flutter: 18+ confirmation dialog for enabling NSFW (moderation rules, not a free-for-all) +- ✅ Flutter: 18+ confirmation dialog for disabling blur (disturbing content warning) +- ✅ Flutter: 6-click path to enable (profile → settings → expand → toggle → confirm → enable) +- ✅ Flutter: blur enforced everywhere — post card, threaded conversation, parent preview, replies +- ✅ Flutter: NSFW self-label toggle in compose toolbar +- ✅ Flutter: `publishPost` API sends `is_nsfw`/`nsfw_reason` + ### Recent Fixes (Feb 2026) - ✅ Notification badge count clears on archive - ✅ Notification UI → full page (was slide-up dialog)