feat: email notifications for deactivate/delete, typed confirmations, chat backup manager, provider invalidation on logout
This commit is contained in:
parent
77ef1ecac5
commit
b51c9ba90b
|
|
@ -51,6 +51,18 @@ func (h *AccountHandler) DeactivateAccount(c *gin.Context) {
|
||||||
// Revoke all tokens so they're logged out everywhere
|
// Revoke all tokens so they're logged out everywhere
|
||||||
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
|
_ = 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")
|
log.Info().Str("user_id", userID).Msg("Account deactivated")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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.",
|
"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")
|
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")
|
log.Info().Str("user_id", userID).Msg("Account scheduled for deletion in 14 days")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
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),
|
"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
|
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 · Sojorn · 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 · Sojorn · 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 {
|
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
|
||||||
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
|
subject := "FINAL WARNING: Confirm Permanent Account Destruction"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'providers/theme_provider.dart' as theme_provider;
|
import 'providers/theme_provider.dart' as theme_provider;
|
||||||
import 'providers/auth_provider.dart';
|
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';
|
import 'routes/app_routes.dart';
|
||||||
|
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
|
|
@ -104,7 +108,7 @@ class _sojornAppState extends ConsumerState<sojornApp> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (uri.host == 'verified') {
|
} else if (uri.host == 'verified') {
|
||||||
ref.read(emailVerifiedEventProvider.notifier).state = true;
|
ref.read(emailVerifiedEventProvider.notifier).set(true);
|
||||||
|
|
||||||
if (_authService.isAuthenticated) {
|
if (_authService.isAuthenticated) {
|
||||||
_authService.refreshSession();
|
_authService.refreshSession();
|
||||||
|
|
@ -135,6 +139,11 @@ class _sojornAppState extends ConsumerState<sojornApp> {
|
||||||
} else if (data.event == AuthChangeEvent.signedOut) {
|
} else if (data.event == AuthChangeEvent.signedOut) {
|
||||||
_syncManager?.dispose();
|
_syncManager?.dispose();
|
||||||
_syncManager = null;
|
_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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,13 @@ final isAuthenticatedProvider = Provider<bool>((ref) {
|
||||||
return authService.currentUser != null;
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
// Direct import of the specific Riverpod classes
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
// Simple provider definition
|
class FeedRefreshNotifier extends Notifier<int> {
|
||||||
final feedRefreshProvider = StateProvider<int>((ref) => 0);
|
@override
|
||||||
|
int build() => 0;
|
||||||
|
|
||||||
|
void increment() => state++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final feedRefreshProvider =
|
||||||
|
NotifierProvider<FeedRefreshNotifier, int>(FeedRefreshNotifier.new);
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,12 @@ class HeaderState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HeaderController extends ChangeNotifier {
|
class HeaderController extends Notifier<HeaderState> {
|
||||||
HeaderState _state = HeaderState.feed();
|
|
||||||
VoidCallback? _feedRefresh;
|
VoidCallback? _feedRefresh;
|
||||||
List<Widget> _feedTrailingActions = const [];
|
List<Widget> _feedTrailingActions = const [];
|
||||||
|
|
||||||
HeaderState get state => _state;
|
@override
|
||||||
|
HeaderState build() => HeaderState.feed();
|
||||||
|
|
||||||
void configureFeed({
|
void configureFeed({
|
||||||
VoidCallback? onRefresh,
|
VoidCallback? onRefresh,
|
||||||
|
|
@ -75,21 +75,19 @@ class HeaderController extends ChangeNotifier {
|
||||||
}) {
|
}) {
|
||||||
_feedRefresh = onRefresh;
|
_feedRefresh = onRefresh;
|
||||||
_feedTrailingActions = trailingActions;
|
_feedTrailingActions = trailingActions;
|
||||||
if (_state.mode == HeaderMode.feed) {
|
if (state.mode == HeaderMode.feed) {
|
||||||
_state = HeaderState.feed(
|
state = HeaderState.feed(
|
||||||
onRefresh: _feedRefresh,
|
onRefresh: _feedRefresh,
|
||||||
trailingActions: _feedTrailingActions,
|
trailingActions: _feedTrailingActions,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setFeed() {
|
void setFeed() {
|
||||||
_state = HeaderState.feed(
|
state = HeaderState.feed(
|
||||||
onRefresh: _feedRefresh,
|
onRefresh: _feedRefresh,
|
||||||
trailingActions: _feedTrailingActions,
|
trailingActions: _feedTrailingActions,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setContext({
|
void setContext({
|
||||||
|
|
@ -98,15 +96,14 @@ class HeaderController extends ChangeNotifier {
|
||||||
VoidCallback? onRefresh,
|
VoidCallback? onRefresh,
|
||||||
List<Widget> trailingActions = const [],
|
List<Widget> trailingActions = const [],
|
||||||
}) {
|
}) {
|
||||||
_state = HeaderState.context(
|
state = HeaderState.context(
|
||||||
title: title,
|
title: title,
|
||||||
onBack: onBack,
|
onBack: onBack,
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
trailingActions: trailingActions,
|
trailingActions: trailingActions,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final headerControllerProvider =
|
final headerControllerProvider =
|
||||||
ChangeNotifierProvider<HeaderController>((ref) => HeaderController());
|
NotifierProvider<HeaderController, HeaderState>(HeaderController.new);
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger feed refresh
|
// Trigger feed refresh
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,15 @@ class SettingsState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsNotifier extends StateNotifier<SettingsState> {
|
class SettingsNotifier extends Notifier<SettingsState> {
|
||||||
final ApiService _apiService;
|
@override
|
||||||
|
SettingsState build() {
|
||||||
SettingsNotifier(this._apiService) : super(SettingsState()) {
|
Future.microtask(() => refresh());
|
||||||
refresh();
|
return SettingsState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiService get _apiService => ApiService.instance;
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -116,6 +118,4 @@ class SettingsNotifier extends StateNotifier<SettingsState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, SettingsState>((ref) {
|
final settingsProvider = NotifierProvider<SettingsNotifier, SettingsState>(SettingsNotifier.new);
|
||||||
return SettingsNotifier(ApiService.instance);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ class UploadProgress {
|
||||||
UploadProgress({this.progress = 0, this.isUploading = false, this.error});
|
UploadProgress({this.progress = 0, this.isUploading = false, this.error});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadNotifier extends StateNotifier<UploadProgress> {
|
class UploadNotifier extends Notifier<UploadProgress> {
|
||||||
UploadNotifier() : super(UploadProgress());
|
@override
|
||||||
|
UploadProgress build() => UploadProgress();
|
||||||
|
|
||||||
void setProgress(double progress) {
|
void setProgress(double progress) {
|
||||||
state = UploadProgress(progress: progress, isUploading = true);
|
state = UploadProgress(progress: progress, isUploading: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
|
|
@ -34,6 +35,4 @@ class UploadNotifier extends StateNotifier<UploadProgress> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final uploadProvider = StateNotifierProvider<UploadNotifier, UploadProgress>((ref) {
|
final uploadProvider = NotifierProvider<UploadNotifier, UploadProgress>(UploadNotifier.new);
|
||||||
return UploadNotifier();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -169,10 +169,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
try {
|
try {
|
||||||
final authenticated = await _localAuth.authenticate(
|
final authenticated = await _localAuth.authenticate(
|
||||||
localizedReason: 'Unlock sojorn with biometrics',
|
localizedReason: 'Unlock sojorn with biometrics',
|
||||||
options: const AuthenticationOptions(
|
|
||||||
biometricOnly: true,
|
|
||||||
stickyAuth: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
|
@ -213,7 +209,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
ref.read(emailVerifiedEventProvider.notifier).state = false;
|
ref.read(emailVerifiedEventProvider.notifier).set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
sojornSnackbar.showSuccess(
|
sojornSnackbar.showSuccess(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -67,134 +67,9 @@ class sojornImageEditor extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
ProImageEditorConfigs _buildConfigs() {
|
ProImageEditorConfigs _buildConfigs() {
|
||||||
// Determine aspect ratio based on post type
|
|
||||||
final aspectRatios = _getAspectRatios();
|
|
||||||
|
|
||||||
return ProImageEditorConfigs(
|
return ProImageEditorConfigs(
|
||||||
theme: _buildEditorTheme(),
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final apiService = ref.read(apiServiceProvider);
|
final apiService = ref.read(apiServiceProvider);
|
||||||
final posts = await apiService.getsojornFeed(
|
final posts = await apiService.getSojornFeed(
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset: refresh ? 0 : _posts.length,
|
offset: refresh ? 0 : _posts.length,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
bool _shouldBlurPost(Post post) {
|
bool _shouldBlurPost(Post post) {
|
||||||
if (!post.isNsfw || _nsfwRevealed.contains(post.id)) return false;
|
if (!post.isNsfw || _nsfwRevealed.contains(post.id)) return false;
|
||||||
final settings = ref.read(settingsProvider);
|
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;
|
return settings.user?.nsfwBlurEnabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -955,11 +955,38 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
const Text('Deactivate Account'),
|
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. '
|
'Your account will be hidden and you will be logged out. '
|
||||||
'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
|
'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
|
||||||
'You can reactivate at any time simply by logging back in.',
|
'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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
|
@ -979,9 +1006,11 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDeleteDialog() {
|
void _showDeleteDialog() {
|
||||||
|
final confirmController = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
|
Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
|
||||||
|
|
@ -989,11 +1018,49 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
const Text('Delete Account'),
|
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'
|
'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. '
|
'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'
|
'After that, ALL data will be irreversibly destroyed.',
|
||||||
'Are you sure you want to proceed?',
|
),
|
||||||
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -1001,22 +1068,27 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: confirmController.text == 'DELETE'
|
||||||
|
? () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
await _performDeletion();
|
await _performDeletion();
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
child: const Text('Delete My Account'),
|
child: const Text('Delete My Account'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSuperDeleteDialog() {
|
void _showSuperDeleteDialog() {
|
||||||
|
final confirmController = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
|
Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
|
||||||
|
|
@ -1027,19 +1099,47 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: const [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'THIS IS IRREVERSIBLE.',
|
'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'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: confirmController.text == 'DESTROY'
|
||||||
|
? () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
await _performSuperDelete();
|
await _performSuperDelete();
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: Colors.white,
|
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');
|
await api.callGoApi('/account/deactivate', method: 'POST');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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();
|
await _signOut();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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 {
|
Future<void> _performDeletion() async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
await api.callGoApi('/account', method: 'DELETE');
|
final result = await api.callGoApi('/account', method: 'DELETE');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final deletionDate = result['deletion_date'] ?? '14 days';
|
||||||
const SnackBar(content: Text('Account scheduled for deletion in 14 days. Log back in to cancel.')),
|
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) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Confirmation Email Sent'),
|
title: Row(
|
||||||
content: const Text(
|
children: [
|
||||||
'A confirmation email has been sent to your registered address.\n\n'
|
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. '
|
'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.',
|
'The link expires in 1 hour.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(ctx),
|
||||||
Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
child: const Text('OK'),
|
child: const Text('OK'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1127,7 +1276,7 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Failed to initiate destroy: $e')),
|
SnackBar(content: Text('Failed to initiate destroy: $e'), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
import '../../models/secure_chat.dart';
|
import '../../models/secure_chat.dart';
|
||||||
import '../../services/secure_chat_service.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 '../../theme/app_theme.dart';
|
||||||
import '../../widgets/media/signed_media_image.dart';
|
import '../../widgets/media/signed_media_image.dart';
|
||||||
import '../home/full_screen_shell.dart';
|
import '../home/full_screen_shell.dart';
|
||||||
|
|
@ -118,13 +118,16 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
return FullScreenShell(
|
return FullScreenShell(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
'Messages',
|
'Messages',
|
||||||
style: GoogleFonts.literata(
|
style: GoogleFonts.literata(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppTheme.navyBlue,
|
color: AppTheme.navyBlue,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -216,7 +219,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const LocalBackupScreen(),
|
builder: (context) => const ChatDataManagementScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/app_scaffold.dart';
|
import '../../widgets/app_scaffold.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../models/profile_privacy_settings.dart';
|
import '../../models/profile_privacy_settings.dart';
|
||||||
|
|
||||||
/// Comprehensive privacy settings screen for managing account privacy
|
/// Comprehensive privacy settings screen for managing account privacy
|
||||||
|
|
@ -129,7 +129,10 @@ class _PrivacySettingsScreenState
|
||||||
},
|
},
|
||||||
child: AppScaffold(
|
child: AppScaffold(
|
||||||
title: 'Privacy Settings',
|
title: 'Privacy Settings',
|
||||||
showBackButton: true,
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -221,12 +224,12 @@ class _PrivacySettingsScreenState
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: AppTheme.primaryColor),
|
Icon(icon, size: 20, color: AppTheme.brightNavy),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: AppTheme.headlineSmall.copyWith(
|
style: AppTheme.headlineSmall.copyWith(
|
||||||
color: AppTheme.primaryColor,
|
color: AppTheme.brightNavy,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -235,7 +238,7 @@ class _PrivacySettingsScreenState
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surfaceColor,
|
color: AppTheme.cardSurface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -252,7 +255,7 @@ class _PrivacySettingsScreenState
|
||||||
subtitle: const Text('Only approved followers can see your posts'),
|
subtitle: const Text('Only approved followers can see your posts'),
|
||||||
value: _settings.isPrivate,
|
value: _settings.isPrivate,
|
||||||
onChanged: (value) => _updateSetting(() => _settings.isPrivate = value),
|
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'),
|
subtitle: const Text('Let others add to your posts'),
|
||||||
value: _settings.allowChains,
|
value: _settings.allowChains,
|
||||||
onChanged: (value) => _updateSetting(() => _settings.allowChains = value),
|
onChanged: (value) => _updateSetting(() => _settings.allowChains = value),
|
||||||
activeColor: AppTheme.primaryColor,
|
activeColor: AppTheme.brightNavy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -308,7 +311,7 @@ class _PrivacySettingsScreenState
|
||||||
value: _settings.showActivityStatus,
|
value: _settings.showActivityStatus,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_updateSetting(() => _settings.showActivityStatus = value),
|
_updateSetting(() => _settings.showActivityStatus = value),
|
||||||
activeColor: AppTheme.primaryColor,
|
activeColor: AppTheme.brightNavy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +322,7 @@ class _PrivacySettingsScreenState
|
||||||
value: _settings.showInSearch,
|
value: _settings.showInSearch,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_updateSetting(() => _settings.showInSearch = value),
|
_updateSetting(() => _settings.showInSearch = value),
|
||||||
activeColor: AppTheme.primaryColor,
|
activeColor: AppTheme.brightNavy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,15 +333,15 @@ class _PrivacySettingsScreenState
|
||||||
value: _settings.showInSuggestions,
|
value: _settings.showInSuggestions,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
_updateSetting(() => _settings.showInSuggestions = value),
|
_updateSetting(() => _settings.showInSuggestions = value),
|
||||||
activeColor: AppTheme.primaryColor,
|
activeColor: AppTheme.brightNavy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCircleInfoTile() {
|
Widget _buildCircleInfoTile() {
|
||||||
return const ListTile(
|
return ListTile(
|
||||||
leading: Icon(Icons.info_outline, color: AppTheme.textSecondary),
|
leading: Icon(Icons.info_outline, color: AppTheme.textSecondary),
|
||||||
title: Text('About Circle'),
|
title: const Text('About Circle'),
|
||||||
subtitle: Text(
|
subtitle: const Text(
|
||||||
'Share posts with only your closest friends. '
|
'Share posts with only your closest friends. '
|
||||||
'Circle members see posts marked "Circle" visibility.',
|
'Circle members see posts marked "Circle" visibility.',
|
||||||
),
|
),
|
||||||
|
|
@ -413,7 +416,7 @@ class _PrivacySettingsScreenState
|
||||||
void _showVisibilityPicker() {
|
void _showVisibilityPicker() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppTheme.surfaceColor,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
|
|
@ -437,9 +440,9 @@ class _PrivacySettingsScreenState
|
||||||
Widget _buildVisibilityOption(String value, String label, IconData icon) {
|
Widget _buildVisibilityOption(String value, String label, IconData icon) {
|
||||||
final isSelected = _settings.defaultVisibility == value;
|
final isSelected = _settings.defaultVisibility == value;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
|
leading: Icon(icon, color: isSelected ? AppTheme.brightNavy : null),
|
||||||
title: Text(label),
|
title: Text(label),
|
||||||
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
|
trailing: isSelected ? Icon(Icons.check, color: AppTheme.brightNavy) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_updateSetting(() => _settings.defaultVisibility = value);
|
_updateSetting(() => _settings.defaultVisibility = value);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
@ -454,7 +457,7 @@ class _PrivacySettingsScreenState
|
||||||
}) {
|
}) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppTheme.surfaceColor,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
|
|
@ -485,9 +488,9 @@ class _PrivacySettingsScreenState
|
||||||
) {
|
) {
|
||||||
final isSelected = currentValue == value;
|
final isSelected = currentValue == value;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(icon, color: isSelected ? AppTheme.primaryColor : null),
|
leading: Icon(icon, color: isSelected ? AppTheme.brightNavy : null),
|
||||||
title: Text(label),
|
title: Text(label),
|
||||||
trailing: isSelected ? const Icon(Icons.check, color: AppTheme.primaryColor) : null,
|
trailing: isSelected ? Icon(Icons.check, color: AppTheme.brightNavy) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onChanged(value);
|
onChanged(value);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
@ -532,7 +535,7 @@ class _PrivacySettingsScreenState
|
||||||
Text('• ${data['posts']?.length ?? 0} posts', style: AppTheme.bodyMedium),
|
Text('• ${data['posts']?.length ?? 0} posts', style: AppTheme.bodyMedium),
|
||||||
Text('• ${data['following']?.length ?? 0} connections', style: AppTheme.bodyMedium),
|
Text('• ${data['following']?.length ?? 0} connections', style: AppTheme.bodyMedium),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
Text(
|
||||||
'The data has been prepared. In a production app, '
|
'The data has been prepared. In a production app, '
|
||||||
'this would be saved to your device.',
|
'this would be saved to your device.',
|
||||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ class AdIntegrationService {
|
||||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||||
return _currentAd;
|
return _currentAd;
|
||||||
}
|
}
|
||||||
'AdIntegrationService: no sponsored post available for $categoryId');
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
|
||||||
|
|
|
||||||
212
sojorn_app/lib/services/chat_backup_manager.dart
Normal file
212
sojorn_app/lib/services/chat_backup_manager.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
import 'chat_backup_manager.dart';
|
||||||
import 'secure_chat_service.dart';
|
import 'secure_chat_service.dart';
|
||||||
import 'simple_e2ee_service.dart';
|
import 'simple_e2ee_service.dart';
|
||||||
import 'local_message_store.dart';
|
import 'local_message_store.dart';
|
||||||
|
|
@ -60,8 +61,11 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
_startTimer();
|
_startTimer();
|
||||||
_syncIfStale();
|
_syncIfStale();
|
||||||
}
|
}
|
||||||
} else if (state == AppLifecycleState.paused ||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
state == AppLifecycleState.inactive ||
|
_stopTimer();
|
||||||
|
// Trigger backup when app goes to background
|
||||||
|
ChatBackupManager.instance.triggerBackupIfNeeded();
|
||||||
|
} else if (state == AppLifecycleState.inactive ||
|
||||||
state == AppLifecycleState.detached) {
|
state == AppLifecycleState.detached) {
|
||||||
_stopTimer();
|
_stopTimer();
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +118,9 @@ class SyncManager with WidgetsBindingObserver {
|
||||||
|
|
||||||
await _secureChatService.syncAllConversations(force: true);
|
await _secureChatService.syncAllConversations(force: true);
|
||||||
_lastSyncAt = DateTime.now();
|
_lastSyncAt = DateTime.now();
|
||||||
|
|
||||||
|
// Schedule auto-backup after successful sync
|
||||||
|
ChatBackupManager.instance.scheduleBackup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
} finally {
|
} finally {
|
||||||
_syncInProgress = false;
|
_syncInProgress = false;
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ class _PostMenuState extends ConsumerState<PostMenu> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh feed to show updated post
|
// Refresh feed to show updated post
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
widget.onPostDeleted?.call();
|
widget.onPostDeleted?.call();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sojornSnackbar.showError(
|
sojornSnackbar.showError(
|
||||||
|
|
@ -152,7 +152,7 @@ class _PostMenuState extends ConsumerState<PostMenu> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh feed to remove deleted post immediately
|
// Refresh feed to remove deleted post immediately
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
widget.onPostDeleted?.call();
|
widget.onPostDeleted?.call();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sojornSnackbar.showError(
|
sojornSnackbar.showError(
|
||||||
|
|
@ -228,7 +228,7 @@ class _PostMenuState extends ConsumerState<PostMenu> {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh feed to show privacy changes
|
// Refresh feed to show privacy changes
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
widget.onPostDeleted?.call();
|
widget.onPostDeleted?.call();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sojornSnackbar.showError(
|
sojornSnackbar.showError(
|
||||||
|
|
@ -261,7 +261,7 @@ class _PostMenuState extends ConsumerState<PostMenu> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh feed to show pin state changes
|
// Refresh feed to show pin state changes
|
||||||
ref.read(feedRefreshProvider.notifier).state++;
|
ref.read(feedRefreshProvider.notifier).increment();
|
||||||
widget.onPostDeleted?.call();
|
widget.onPostDeleted?.call();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sojornSnackbar.showError(
|
sojornSnackbar.showError(
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,10 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
bool get _shouldBlurNsfw {
|
bool get _shouldBlurNsfw {
|
||||||
if (!post.isNsfw || _nsfwRevealed) return false;
|
if (!post.isNsfw || _nsfwRevealed) return false;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final blurEnabled = settings.user?.nsfwBlurEnabled ?? true;
|
// Always blur if user hasn't opted into NSFW content
|
||||||
return blurEnabled;
|
if (!(settings.user?.nsfwEnabled ?? false)) return true;
|
||||||
|
// If opted in, respect the blur toggle
|
||||||
|
return settings.user?.nsfwBlurEnabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _avatarSize {
|
double get _avatarSize {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,38 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
_flutterfire_internals:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _flutterfire_internals
|
name: _flutterfire_internals
|
||||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
app_links:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: app_links
|
name: app_links
|
||||||
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
|
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.4.1"
|
version: "7.0.0"
|
||||||
app_links_linux:
|
app_links_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -101,26 +117,26 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: camera
|
name: camera
|
||||||
sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8
|
sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.6"
|
version: "0.11.3"
|
||||||
camera_android:
|
camera_android_camerax:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: camera_android
|
name: camera_android_camerax
|
||||||
sha256: "50c0d1c4b122163e3d7cdfcd6d4cd8078aac27d0f1cd1e7b3fa69e6b3f06f4b7"
|
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.10.10+14"
|
version: "0.6.30"
|
||||||
camera_avfoundation:
|
camera_avfoundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: camera_avfoundation
|
name: camera_avfoundation
|
||||||
sha256: a600b60a7752cc5fa9de476cd0055539d7a3b9d62662f4f446bae49eba2267df
|
sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.22+9"
|
version: "0.9.23+2"
|
||||||
camera_platform_interface:
|
camera_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -169,6 +185,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.13.0"
|
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:
|
cli_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -217,6 +241,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -285,18 +317,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.12"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.5.0"
|
version: "12.3.0"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -329,14 +361,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -421,10 +445,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_core
|
name: firebase_core
|
||||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.15.2"
|
version: "4.4.0"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -437,34 +461,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_web
|
name: firebase_core_web
|
||||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.24.1"
|
version: "3.4.0"
|
||||||
firebase_messaging:
|
firebase_messaging:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_messaging
|
name: firebase_messaging
|
||||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.10"
|
version: "16.1.1"
|
||||||
firebase_messaging_platform_interface:
|
firebase_messaging_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_messaging_platform_interface
|
name: firebase_messaging_platform_interface
|
||||||
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.10"
|
version: "4.7.6"
|
||||||
firebase_messaging_web:
|
firebase_messaging_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_messaging_web
|
name: firebase_messaging_web
|
||||||
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.10.10"
|
version: "4.1.2"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -554,66 +578,74 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview
|
name: flutter_inappwebview
|
||||||
sha256: a8f5c9dd300a8cc7fde7bb902ae57febe95e9269424e4d08d5a1a56214e1e6ff
|
sha256: "3952d116ee93bad2946401377e7ade87b5ef200e95ecb5ba1affa1b6329a6867"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.0-beta.2"
|
version: "6.2.0-beta.3"
|
||||||
flutter_inappwebview_android:
|
flutter_inappwebview_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_android
|
name: flutter_inappwebview_android
|
||||||
sha256: "2427e89d9c7b00cc756f800932d7ab8f3272d3fbc71544e1aedb3dbc17dae074"
|
sha256: "8dfb76bd4e507112c3942c2272eeb01fab2e42be11374e5eb226f58698e7a04b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0-beta.2"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_internal_annotations:
|
flutter_inappwebview_internal_annotations:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_internal_annotations
|
name: flutter_inappwebview_internal_annotations
|
||||||
sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd"
|
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.3.0"
|
||||||
flutter_inappwebview_ios:
|
flutter_inappwebview_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_ios
|
name: flutter_inappwebview_ios
|
||||||
sha256: "7ff65d7408e453f9a4ff38f74673aeec8cae824cba8276b4b77350262bfe356a"
|
sha256: ae8a78829398771be863aa3c8804a9d40728e1815e66c9c966f86d2cc3ae4fd9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
flutter_inappwebview_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_macos
|
name: flutter_inappwebview_macos
|
||||||
sha256: be8b8ab0100c94ec9fc079a4d48b2bc8dd1a8b4c2647da34f1d3dae93cd5f88a
|
sha256: "545148cb5c46475ce669ab21621e9f2ad66e05f8e80b2cf49d4018879ab52393"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0-beta.2"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_platform_interface:
|
flutter_inappwebview_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_platform_interface
|
name: flutter_inappwebview_platform_interface
|
||||||
sha256: "2c99bf767900ba029d825bc6f494d30169ee83cdaa038d86e85fe70571d0a655"
|
sha256: e3522c76e6760d1c0a9ff690e30e1503f226783d3277fa4d26675911977e9766
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0-beta.2"
|
version: "1.4.0-beta.3"
|
||||||
flutter_inappwebview_web:
|
flutter_inappwebview_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_web
|
name: flutter_inappwebview_web
|
||||||
sha256: "6c4bb61ea9d52e51d79ea23da27c928d0430873c04ad380df39c1ef442b11f4e"
|
sha256: e98b8875ccb6a3fd255873318db45c18ab135ed0ed22d20169abad9f5c810eb9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0-beta.2"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_windows:
|
flutter_inappwebview_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview_windows
|
name: flutter_inappwebview_windows
|
||||||
sha256: "0ff241f814b7caff63b9632cf858b6d3d9c35758040620a9745e5f6e9dd94d74"
|
sha256: "902edd6f6326952af822e21aa928f7426d723d45c94c15e6ce3c2d5640d28ad7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0-beta.2"
|
version: "0.7.0-beta.3"
|
||||||
flutter_keyboard_visibility_linux:
|
flutter_keyboard_visibility_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -658,10 +690,10 @@ packages:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.1"
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -727,58 +759,58 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_riverpod
|
name: flutter_riverpod
|
||||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
sha256: e2026c72738a925a60db30258ff1f29974e40716749f3c9850aabf34ffc1a14c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.2.1"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
flutter_secure_storage_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_linux
|
name: flutter_secure_storage_linux
|
||||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.3"
|
version: "3.0.0"
|
||||||
flutter_secure_storage_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_secure_storage_macos
|
|
||||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.3"
|
|
||||||
flutter_secure_storage_platform_interface:
|
flutter_secure_storage_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_platform_interface
|
name: flutter_secure_storage_platform_interface
|
||||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "2.0.1"
|
||||||
flutter_secure_storage_web:
|
flutter_secure_storage_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_web
|
name: flutter_secure_storage_web
|
||||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "2.1.0"
|
||||||
flutter_secure_storage_windows:
|
flutter_secure_storage_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage_windows
|
name: flutter_secure_storage_windows
|
||||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "4.1.0"
|
||||||
flutter_shaders:
|
flutter_shaders:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -805,22 +837,38 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
geolocator:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: geolocator
|
name: geolocator
|
||||||
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27"
|
sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.1.0"
|
version: "14.0.2"
|
||||||
geolocator_android:
|
geolocator_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_android
|
name: geolocator_android
|
||||||
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.2"
|
version: "5.0.2"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -829,6 +877,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.13"
|
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:
|
geolocator_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -841,10 +897,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_web
|
name: geolocator_web
|
||||||
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
|
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "4.1.3"
|
||||||
geolocator_windows:
|
geolocator_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -865,18 +921,26 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: b453934c36e289cef06525734d1e676c1f91da9e22e2017d9dcab6ce0f999175
|
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.1.3"
|
version: "17.1.0"
|
||||||
google_fonts:
|
google_fonts:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
sha256: c30eef5e7cd26eb89cc8065b4390ac86ce579f2fcdbe35220891c6278b5460da
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
gtk:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -905,10 +969,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7"
|
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -925,6 +989,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
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:
|
http_parser:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1013,14 +1085,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.0"
|
version: "0.19.0"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.7.2"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1081,26 +1161,26 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: local_auth
|
name: local_auth
|
||||||
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
sha256: a4f1bf57f0236a4aeb5e8f0ec180e197f4b112a3456baa6c1e73b546630b0422
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "3.0.0"
|
||||||
local_auth_android:
|
local_auth_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_android
|
name: local_auth_android
|
||||||
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
sha256: "162b8e177fd9978c4620da2a8002a5c6bed4d20f0c6daf5137e72e9a8b767d2e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.56"
|
version: "2.0.4"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_darwin
|
name: local_auth_darwin
|
||||||
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
sha256: "668ea65edaab17380956e9713f57e34f78ede505ca0cfd8d39db34e2f260bfee"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.1"
|
version: "2.0.1"
|
||||||
local_auth_platform_interface:
|
local_auth_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1113,10 +1193,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_windows
|
name: local_auth_windows
|
||||||
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
sha256: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.11"
|
version: "2.0.1"
|
||||||
logger:
|
logger:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1197,14 +1277,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e"
|
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.5"
|
version: "9.3.0"
|
||||||
octo_image:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1213,6 +1301,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1297,18 +1393,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.4.0"
|
version: "12.0.1"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.1.0"
|
version: "13.0.1"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1377,10 +1473,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pointycastle
|
name: pointycastle
|
||||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1393,18 +1497,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pro_image_editor
|
name: pro_image_editor
|
||||||
sha256: "27190b0333af71e9949f366ac303496511ef6d67607f6f9797c9f136371a321f"
|
sha256: "715be7071919fa40ef47410ab44a4eecbd2080fc196bad3aec270965d2c84b16"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.2.3"
|
version: "11.22.1"
|
||||||
pro_video_editor:
|
pro_video_editor:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: pro_video_editor
|
name: pro_video_editor
|
||||||
sha256: "18f62235212ff779a2ca967df4ce06cac22b7ff45051f46519754d94db2b04ff"
|
sha256: c88186f69cf0649f19ec20ad3a60c89ac98f940df43eed7b7601195bdcd0246b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.5.0"
|
||||||
proj4dart:
|
proj4dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1497,10 +1601,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: riverpod
|
name: riverpod
|
||||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "3.2.1"
|
||||||
rxdart:
|
rxdart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1581,19 +1685,67 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: source_span
|
name: source_span
|
||||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1690,6 +1842,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
|
test:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test
|
||||||
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1698,6 +1858,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
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:
|
timeago:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1850,22 +2018,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
video_editor:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1894,10 +2046,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: video_player_avfoundation
|
name: video_player_avfoundation
|
||||||
sha256: "7cc0a9257103851eb299a2407e895b0fd6832d323dcfde622a23cdc25a1de269"
|
sha256: f46e9e20f1fe429760cf4dc118761336320d1bec0f50d255930c2355f2defb5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.0"
|
version: "2.9.1"
|
||||||
video_player_platform_interface:
|
video_player_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1954,6 +2106,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1978,6 +2138,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Backend Services (Fully migrated to Go API)
|
# Backend Services (Fully migrated to Go API)
|
||||||
firebase_core: ^3.4.0
|
firebase_core: ^4.4.0
|
||||||
firebase_messaging: ^15.1.0
|
firebase_messaging: ^16.1.1
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^3.2.1
|
||||||
|
|
||||||
# HTTP & API
|
# HTTP & API
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
|
|
@ -24,8 +24,8 @@ dependencies:
|
||||||
|
|
||||||
# UI & Utilities
|
# UI & Utilities
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^8.0.1
|
||||||
share_plus: ^10.0.2
|
share_plus: ^10.1.4
|
||||||
timeago: ^3.7.0
|
timeago: ^3.7.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
|
|
@ -48,12 +48,12 @@ dependencies:
|
||||||
markdown: ^7.3.0
|
markdown: ^7.3.0
|
||||||
|
|
||||||
# Image Editing
|
# Image Editing
|
||||||
pro_image_editor: ^6.0.0
|
pro_image_editor: ^11.22.1
|
||||||
pro_video_editor: ^1.3.0
|
pro_video_editor: ^1.3.0
|
||||||
camera: ^0.10.0+1
|
camera: ^0.11.3
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^15.1.0
|
go_router: ^17.1.0
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.3.4
|
shared_preferences: ^2.3.4
|
||||||
|
|
@ -63,32 +63,33 @@ dependencies:
|
||||||
# E2EE Cryptography
|
# E2EE Cryptography
|
||||||
cryptography: ^2.5.0
|
cryptography: ^2.5.0
|
||||||
convert: ^3.1.1
|
convert: ^3.1.1
|
||||||
pointycastle: ^3.7.3
|
pointycastle: ^4.0.0
|
||||||
file_picker: ^10.3.10
|
file_picker: ^10.3.10
|
||||||
universal_html: ^2.0.8
|
universal_html: ^2.0.8
|
||||||
|
|
||||||
# Maps and Location
|
# Maps and Location
|
||||||
flutter_map: ^8.2.0
|
flutter_map: ^8.2.0
|
||||||
latlong2: ^0.9.1
|
latlong2: ^0.9.1
|
||||||
geolocator: ^11.0.0
|
geolocator: ^14.0.2
|
||||||
permission_handler: ^11.3.0
|
permission_handler: ^12.0.1
|
||||||
flutter_secure_storage: ^9.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
local_auth: ^2.2.0
|
local_auth: ^3.0.0
|
||||||
http_parser: ^4.1.2
|
http_parser: ^4.1.2
|
||||||
app_links: ^6.3.2
|
app_links: ^7.0.0
|
||||||
cloudflare_turnstile: ^3.6.2
|
cloudflare_turnstile: ^3.6.2
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
video_editor: ^3.0.0
|
video_editor: ^3.0.0
|
||||||
chewie: ^1.10.0
|
chewie: ^1.10.0
|
||||||
intl: 0.19.0
|
intl: 0.19.0
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
|
device_info_plus: ^12.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <app_links/app_links_plugin_c_api.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 <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||||
|
|
@ -22,8 +21,6 @@
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AppLinksPluginCApiRegisterWithRegistrar(
|
AppLinksPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||||
EmojiPickerFlutterPluginCApiRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi"));
|
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
app_links
|
app_links
|
||||||
emoji_picker_flutter
|
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_inappwebview_windows
|
flutter_inappwebview_windows
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Sojorn Development TODO
|
# 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
|
- ✅ GeoIP for location features
|
||||||
- ✅ Automated deploy scripts
|
- ✅ 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)
|
### Recent Fixes (Feb 2026)
|
||||||
- ✅ Notification badge count clears on archive
|
- ✅ Notification badge count clears on archive
|
||||||
- ✅ Notification UI → full page (was slide-up dialog)
|
- ✅ Notification UI → full page (was slide-up dialog)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue