feat: email notifications for deactivate/delete, typed confirmations, chat backup manager, provider invalidation on logout

This commit is contained in:
Patrick Britton 2026-02-07 23:39:51 -06:00
parent 77ef1ecac5
commit b51c9ba90b
27 changed files with 2005 additions and 436 deletions

View file

@ -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(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 1px solid #334155;">
<h1 style="color: #f59e0b; font-size: 22px; margin: 0 0 24px 0;">Account Deactivated</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been deactivated. Your profile is now hidden from other users.</p>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; margin: 0 0 8px 0; font-weight: bold;">What this means:</p>
<ul style="color: #94a3b8; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your profile, posts, and connections are hidden but <strong>fully preserved</strong></li>
<li>No one can see your account while it is deactivated</li>
<li>You can reactivate at any time simply by <strong>logging back in</strong></li>
</ul>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, 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(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #ef4444;">
<h1 style="color: #ef4444; font-size: 22px; margin: 0 0 24px 0;">Account Deletion Scheduled</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">Your Sojorn account has been scheduled for <strong>permanent deletion on %s</strong>.</p>
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">What happens next:</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>Your account is immediately deactivated and hidden</li>
<li>On <strong>%s</strong>, all data will be permanently and irreversibly destroyed</li>
<li>This includes posts, messages, encryption keys, profile, followers, and your handle</li>
</ul>
</div>
<div style="background: #1e293b; border: 1px solid #475569; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">Changed your mind?</p>
<p style="color: #94a3b8; font-size: 14px; margin: 0; line-height: 1.6;">Simply <strong>log back in</strong> before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.</p>
</div>
<p style="font-size: 14px; color: #888; line-height: 1.6;">If you did not request this, please log back in immediately to cancel the deletion and secure your account.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, 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"

View file

@ -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<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
@ -104,7 +108,7 @@ class _sojornAppState extends ConsumerState<sojornApp> {
);
}
} 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<sojornApp> {
} 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();
}
});
}

View file

@ -23,4 +23,13 @@ final isAuthenticatedProvider = Provider<bool>((ref) {
return authService.currentUser != null;
});
final emailVerifiedEventProvider = StateProvider<bool>((ref) => false);
class EmailVerifiedEventNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
final emailVerifiedEventProvider =
NotifierProvider<EmailVerifiedEventNotifier, bool>(
EmailVerifiedEventNotifier.new);

View file

@ -1,5 +1,11 @@
// Direct import of the specific Riverpod classes
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Simple provider definition
final feedRefreshProvider = StateProvider<int>((ref) => 0);
class FeedRefreshNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
final feedRefreshProvider =
NotifierProvider<FeedRefreshNotifier, int>(FeedRefreshNotifier.new);

View file

@ -62,12 +62,12 @@ class HeaderState {
}
}
class HeaderController extends ChangeNotifier {
HeaderState _state = HeaderState.feed();
class HeaderController extends Notifier<HeaderState> {
VoidCallback? _feedRefresh;
List<Widget> _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<Widget> trailingActions = const [],
}) {
_state = HeaderState.context(
state = HeaderState.context(
title: title,
onBack: onBack,
onRefresh: onRefresh,
trailingActions: trailingActions,
);
notifyListeners();
}
}
final headerControllerProvider =
ChangeNotifierProvider<HeaderController>((ref) => HeaderController());
NotifierProvider<HeaderController, HeaderState>(HeaderController.new);

View file

@ -113,7 +113,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
);
// Trigger feed refresh
ref.read(feedRefreshProvider.notifier).state++;
ref.read(feedRefreshProvider.notifier).increment();
state = state.copyWith(
isUploading: false,

View file

@ -41,13 +41,15 @@ class SettingsState {
}
}
class SettingsNotifier extends StateNotifier<SettingsState> {
final ApiService _apiService;
SettingsNotifier(this._apiService) : super(SettingsState()) {
refresh();
class SettingsNotifier extends Notifier<SettingsState> {
@override
SettingsState build() {
Future.microtask(() => refresh());
return SettingsState();
}
ApiService get _apiService => ApiService.instance;
Future<void> refresh() async {
state = state.copyWith(isLoading: true, error: null);
try {
@ -116,6 +118,4 @@ class SettingsNotifier extends StateNotifier<SettingsState> {
}
}
final settingsProvider = StateNotifierProvider<SettingsNotifier, SettingsState>((ref) {
return SettingsNotifier(ApiService.instance);
});
final settingsProvider = NotifierProvider<SettingsNotifier, SettingsState>(SettingsNotifier.new);

View file

@ -8,11 +8,12 @@ class UploadProgress {
UploadProgress({this.progress = 0, this.isUploading = false, this.error});
}
class UploadNotifier extends StateNotifier<UploadProgress> {
UploadNotifier() : super(UploadProgress());
class UploadNotifier extends Notifier<UploadProgress> {
@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<UploadProgress> {
}
}
final uploadProvider = StateNotifierProvider<UploadNotifier, UploadProgress>((ref) {
return UploadNotifier();
});
final uploadProvider = NotifierProvider<UploadNotifier, UploadProgress>(UploadNotifier.new);

View file

@ -169,10 +169,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
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<SignInScreen> {
behavior: SnackBarBehavior.floating,
),
);
ref.read(emailVerifiedEventProvider.notifier).state = false;
ref.read(emailVerifiedEventProvider.notifier).set(false);
}
});

View file

@ -402,7 +402,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
);
if (mounted) {
ref.read(feedRefreshProvider.notifier).state++;
ref.read(feedRefreshProvider.notifier).increment();
Navigator.of(context).pop(true);
sojornSnackbar.showSuccess(
context: context,

View file

@ -67,134 +67,9 @@ 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<String, dynamic> _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

View file

@ -66,7 +66,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
try {
final apiService = ref.read(apiServiceProvider);
final posts = await apiService.getsojornFeed(
final posts = await apiService.getSojornFeed(
limit: 20,
offset: refresh ? 0 : _posts.length,
);

View file

@ -52,6 +52,9 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
bool _shouldBlurPost(Post post) {
if (!post.isNsfw || _nsfwRevealed.contains(post.id)) return false;
final settings = ref.read(settingsProvider);
// 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;
}

View file

@ -955,11 +955,38 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
const Text('Deactivate Account'),
],
),
content: const Text(
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(
onPressed: () => Navigator.pop(ctx),
@ -979,9 +1006,11 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
}
void _showDeleteDialog() {
final confirmController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Row(
children: [
Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
@ -989,11 +1018,49 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
const Text('Delete Account'),
],
),
content: const Text(
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 — posts, messages, encryption keys, profile, everything.\n\n'
'Are you sure you want to proceed?',
'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(
@ -1001,22 +1068,27 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
onPressed: confirmController.text == 'DELETE'
? () async {
Navigator.pop(ctx);
await _performDeletion();
},
}
: null,
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete My Account'),
),
],
),
),
);
}
void _showSuperDeleteDialog() {
final confirmController = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
@ -1027,19 +1099,47 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
children: [
Text(
'THIS IS IRREVERSIBLE.',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
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,
),
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.',
),
],
),
@ -1049,18 +1149,21 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
onPressed: confirmController.text == 'DESTROY'
? () async {
Navigator.pop(ctx);
await _performSuperDelete();
},
}
: null,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.red.shade800,
backgroundColor: confirmController.text == 'DESTROY' ? Colors.red.shade800 : Colors.grey,
),
child: const Text('Send Destroy Confirmation Email'),
child: const Text('Send Destroy Email'),
),
],
),
),
);
}
@ -1070,13 +1173,16 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
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<ProfileSettingsScreen> {
Future<void> _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<ProfileSettingsScreen> {
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'
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. '
'If you did not mean to do this, simply ignore the email — your account will not be affected.\n\n'
'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<ProfileSettingsScreen> {
} 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),
);
}
}

View file

@ -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<ChatDataManagementScreen> createState() => _ChatDataManagementScreenState();
}
class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
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<void> _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<void> _showSetupDialog() async {
final passwordController = TextEditingController();
final confirmController = TextEditingController();
bool obscure = true;
final password = await showDialog<String>(
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<void> _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<void> _showChangePasswordDialog() async {
final controller = TextEditingController();
final confirmController = TextEditingController();
final password = await showDialog<String>(
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<void> _confirmDisableBackup() async {
final confirmed = await showDialog<bool>(
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<void> _showRestoreDialog() async {
final controller = TextEditingController();
final password = await showDialog<String>(
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<void> _exportToFile() async {
final controller = TextEditingController();
final password = await showDialog<String>(
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<void> _importFromFile() async {
try {
final backup = await LocalKeyBackupService.loadBackupFromDevice();
if (!mounted) return;
final controller = TextEditingController();
final password = await showDialog<String>(
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<void> _confirmClearMessages() async {
final confirmed = await showDialog<bool>(
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<void> _confirmResetKeys() async {
final confirmed = await showDialog<bool>(
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}';
}
}

View file

@ -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,13 +118,16 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
return FullScreenShell(
title: Row(
children: [
Text(
Flexible(
child: Text(
'Messages',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 20,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
@ -216,7 +219,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LocalBackupScreen(),
builder: (context) => const ChatDataManagementScreen(),
),
);
},

View file

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

View file

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

View file

@ -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<bool> 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<bool> get hasPassword async {
final pw = await _secureStorage.read(key: _passwordKey);
return pw != null && pw.isNotEmpty;
}
/// Last backup timestamp
Future<DateTime?> 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<int> 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<void> 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<void> 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<void> 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<void> triggerBackupIfNeeded() async {
await _tryAutoBackup();
}
/// Force an immediate backup regardless of schedule.
Future<void> forceBackup() async {
await _performBackup();
}
/// Restore from cloud backup using the given password.
/// Returns restore result with counts.
Future<Map<String, dynamic>> restoreFromCloud(String password) async {
return await LocalKeyBackupService.restoreFromCloud(
password: password,
e2eeService: _e2ee,
);
}
/// Get current local message count for status display.
Future<int> getLocalMessageCount() async {
final records = await LocalMessageStore.instance.getAllMessageRecords();
return records.length;
}
/// Clear all local message data.
Future<void> 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<void> _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<void> _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;
}
}
}

View file

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

View file

@ -105,7 +105,7 @@ class _PostMenuState extends ConsumerState<PostMenu> {
);
// 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<PostMenu> {
);
// 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<PostMenu> {
);
// 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<PostMenu> {
}
// Refresh feed to show pin state changes
ref.read(feedRefreshProvider.notifier).state++;
ref.read(feedRefreshProvider.notifier).increment();
widget.onPostDeleted?.call();
} catch (e) {
sojornSnackbar.showError(

View file

@ -95,8 +95,10 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
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 {

View file

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

View file

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

View file

@ -7,7 +7,6 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <emoji_picker_flutter/emoji_picker_flutter_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
@ -22,8 +21,6 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
EmojiPickerFlutterPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(

View file

@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
emoji_picker_flutter
file_selector_windows
firebase_core
flutter_inappwebview_windows

View file

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