feat: add NSFW self-labeling toggle in compose, blur setting in user preferences, improve signup date pickers with modal sheets

- Add `isNsfw` checkbox to compose screen for user self-labeling
- Add `nsfwBlurEnabled` setting to UserSettings model (default true)
- Replace signup month/year dropdowns with modal bottom sheet pickers
- Preload Noto Color Emoji font on app startup
- Refactor notifications/discover screens to use FullScreenShell component
This commit is contained in:
Patrick Britton 2026-02-07 18:15:54 -06:00
parent 8d00dc4fda
commit bc35eea69b
15 changed files with 873 additions and 356 deletions

View file

@ -13,6 +13,7 @@ import 'services/auth_service.dart';
import 'services/secure_chat_service.dart';
import 'services/simple_e2ee_service.dart';
import 'services/sync_manager.dart';
import 'package:google_fonts/google_fonts.dart';
import 'theme/app_theme.dart';
import 'providers/theme_provider.dart' as theme_provider;
import 'providers/auth_provider.dart';
@ -32,6 +33,10 @@ void main() async {
}
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
GoogleFonts.pendingFonts([
GoogleFonts.notoColorEmoji(),
]);
runApp(
const ProviderScope(
child: sojornApp(),

View file

@ -10,6 +10,7 @@ class UserSettings {
final bool dataSaverMode;
final int? defaultPostTtl;
final bool nsfwEnabled;
final bool nsfwBlurEnabled;
const UserSettings({
required this.userId,
@ -23,6 +24,7 @@ class UserSettings {
this.dataSaverMode = false,
this.defaultPostTtl,
this.nsfwEnabled = false,
this.nsfwBlurEnabled = true,
});
factory UserSettings.fromJson(Map<String, dynamic> json) {
@ -38,6 +40,7 @@ class UserSettings {
dataSaverMode: json['data_saver_mode'] as bool? ?? false,
defaultPostTtl: _parseIntervalHours(json['default_post_ttl']),
nsfwEnabled: json['nsfw_enabled'] as bool? ?? false,
nsfwBlurEnabled: json['nsfw_blur_enabled'] as bool? ?? true,
);
}
@ -54,6 +57,7 @@ class UserSettings {
'data_saver_mode': dataSaverMode,
'default_post_ttl': defaultPostTtl,
'nsfw_enabled': nsfwEnabled,
'nsfw_blur_enabled': nsfwBlurEnabled,
};
}
@ -68,6 +72,7 @@ class UserSettings {
bool? dataSaverMode,
int? defaultPostTtl,
bool? nsfwEnabled,
bool? nsfwBlurEnabled,
}) {
return UserSettings(
userId: userId,
@ -81,6 +86,7 @@ class UserSettings {
dataSaverMode: dataSaverMode ?? this.dataSaverMode,
defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled,
nsfwBlurEnabled: nsfwBlurEnabled ?? this.nsfwBlurEnabled,
);
}

View file

@ -144,6 +144,75 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
}
}
void _showMonthPicker() {
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text('Select Month', style: AppTheme.headlineSmall),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: 12,
itemBuilder: (ctx, i) => ListTile(
title: Text(months[i]),
selected: _birthMonth == i + 1,
onTap: () {
setState(() => _birthMonth = i + 1);
Navigator.pop(ctx);
},
),
),
),
],
),
),
);
}
void _showYearPicker() {
final currentYear = DateTime.now().year;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text('Select Year', style: AppTheme.headlineSmall),
),
const Divider(height: 1),
SizedBox(
height: 300,
child: ListView.builder(
itemCount: currentYear - 1900 + 1,
itemBuilder: (ctx, i) {
final year = currentYear - i;
return ListTile(
title: Text('$year'),
selected: _birthYear == year,
onTap: () {
setState(() => _birthYear = year);
Navigator.pop(ctx);
},
);
},
),
),
],
),
),
);
}
void _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
@ -315,42 +384,48 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
const SizedBox(height: AppTheme.spacingSm),
Row(
children: [
// Month dropdown
// Month picker
Expanded(
flex: 3,
child: DropdownButtonFormField<int>(
value: _birthMonth,
decoration: const InputDecoration(
child: GestureDetector(
onTap: () => _showMonthPicker(),
child: AbsorbPointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Month',
prefixIcon: Icon(Icons.calendar_month),
prefixIcon: const Icon(Icons.calendar_month),
hintText: 'Select',
suffixIcon: const Icon(Icons.arrow_drop_down),
),
controller: TextEditingController(
text: _birthMonth != null
? const ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][_birthMonth! - 1]
: '',
),
validator: (_) => _birthMonth == null ? 'Required' : null,
),
),
items: List.generate(12, (i) {
final month = i + 1;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return DropdownMenuItem(value: month, child: Text(months[i]));
}),
onChanged: (v) => setState(() => _birthMonth = v),
validator: (v) => v == null ? 'Required' : null,
),
),
const SizedBox(width: AppTheme.spacingSm),
// Year dropdown
// Year picker
Expanded(
flex: 2,
child: DropdownButtonFormField<int>(
value: _birthYear,
decoration: const InputDecoration(
child: GestureDetector(
onTap: () => _showYearPicker(),
child: AbsorbPointer(
child: TextFormField(
decoration: InputDecoration(
labelText: 'Year',
hintText: 'Select',
suffixIcon: const Icon(Icons.arrow_drop_down),
),
controller: TextEditingController(
text: _birthYear != null ? '$_birthYear' : '',
),
validator: (_) => _birthYear == null ? 'Required' : null,
),
items: List.generate(
DateTime.now().year - 1900 + 1,
(i) {
final year = DateTime.now().year - i;
return DropdownMenuItem(value: year, child: Text('$year'));
},
),
onChanged: (v) => setState(() => _birthYear = v),
validator: (v) => v == null ? 'Required' : null,
),
),
],

View file

@ -42,6 +42,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
String? _blockedMessage;
final int _maxCharacters = 500;
bool _allowChain = true;
bool _isNsfw = false;
bool _isBold = false;
bool _isItalic = false;
int? _ttlHoursOverride;
@ -397,6 +398,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
imageUrl: imageUrl,
ttlHours: _ttlHoursOverride,
userWarned: userWarned,
isNsfw: _isNsfw,
);
if (mounted) {
@ -630,12 +632,14 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
onToggleBold: _toggleBold,
onToggleItalic: _toggleItalic,
onToggleChain: _toggleChain,
onToggleNsfw: () => setState(() => _isNsfw = !_isNsfw),
onSelectTtl: _openTtlSelector,
ttlOverrideActive: _ttlHoursOverride != null,
ttlLabel: _ttlOptions[_ttlHoursOverride] ?? 'Use default',
isBold: _isBold,
isItalic: _isItalic,
allowChain: _allowChain,
isNsfw: _isNsfw,
characterCount: count,
maxCharacters: _maxCharacters,
isUploadingImage: _isUploadingImage,
@ -875,10 +879,12 @@ class ComposeBottomBar extends StatelessWidget {
final VoidCallback onToggleBold;
final VoidCallback onToggleItalic;
final VoidCallback onToggleChain;
final VoidCallback? onToggleNsfw;
final VoidCallback onSelectTtl;
final bool isBold;
final bool isItalic;
final bool allowChain;
final bool isNsfw;
final bool ttlOverrideActive;
final String ttlLabel;
final int characterCount;
@ -892,12 +898,14 @@ class ComposeBottomBar extends StatelessWidget {
required this.onToggleBold,
required this.onToggleItalic,
required this.onToggleChain,
this.onToggleNsfw,
required this.onSelectTtl,
required this.ttlOverrideActive,
required this.ttlLabel,
required this.isBold,
required this.isItalic,
required this.allowChain,
this.isNsfw = false,
required this.characterCount,
required this.maxCharacters,
required this.isUploadingImage,
@ -926,12 +934,14 @@ class ComposeBottomBar extends StatelessWidget {
onToggleBold: onToggleBold,
onToggleItalic: onToggleItalic,
onToggleChain: onToggleChain,
onToggleNsfw: onToggleNsfw,
onSelectTtl: onSelectTtl,
ttlOverrideActive: ttlOverrideActive,
ttlLabel: ttlLabel,
isBold: isBold,
isItalic: isItalic,
allowChain: allowChain,
isNsfw: isNsfw,
characterCount: characterCount,
maxCharacters: maxCharacters,
isUploadingImage: isUploadingImage,

View file

@ -13,10 +13,7 @@ import '../profile/viewable_profile_screen.dart';
import '../compose/compose_screen.dart';
import '../post/post_detail_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import '../../providers/notification_provider.dart';
import '../home/full_screen_shell.dart';
/// Model for discover page data
class DiscoverData {
@ -280,57 +277,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
backgroundColor: AppTheme.scaffoldBg,
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
title: Text(
'Search',
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
actions: [
IconButton(
onPressed: () => context.go('/home'),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true,
),
),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
const SizedBox(width: 8),
],
),
return FullScreenShell(
titleText: 'Search',
showSearch: false,
body: Column(
children: [
_buildHeader(),

View file

@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import '../../theme/app_theme.dart';
import '../../routes/app_routes.dart';
import '../../providers/notification_provider.dart';
import '../discover/discover_screen.dart';
import '../secure_chat/secure_chat_full_screen.dart';
/// Unified shell for full-screen pages that sit outside the main tab navigation.
/// Provides a consistent top bar with back button, title, and standard actions
/// (home, search, messages) matching the thread screen pattern.
class FullScreenShell extends ConsumerWidget {
/// The page title displayed in the app bar.
final Widget? title;
/// Simple string title convenience alternative to [title] widget.
final String? titleText;
/// The page body content.
final Widget body;
/// Extra action buttons inserted *before* the standard home/search/messages.
final List<Widget>? leadingActions;
/// Whether to show the standard Home button in actions.
final bool showHome;
/// Whether to show the standard Search button in actions.
final bool showSearch;
/// Whether to show the standard Messages button (with badge) in actions.
final bool showMessages;
/// Optional bottom widget for the AppBar (e.g. TabBar).
final PreferredSizeWidget? bottom;
/// Optional floating action button.
final Widget? floatingActionButton;
/// Optional bottom navigation bar.
final Widget? bottomNavigationBar;
const FullScreenShell({
super.key,
this.title,
this.titleText,
required this.body,
this.leadingActions,
this.showHome = true,
this.showSearch = true,
this.showMessages = true,
this.bottom,
this.floatingActionButton,
this.bottomNavigationBar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: _buildAppBar(context, ref),
body: body,
floatingActionButton: floatingActionButton,
bottomNavigationBar: bottomNavigationBar,
);
}
PreferredSizeWidget _buildAppBar(BuildContext context, WidgetRef ref) {
return AppBar(
backgroundColor: AppTheme.scaffoldBg,
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go(AppRoutes.homeAlias);
}
},
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
title: title ?? (titleText != null
? Text(
titleText!,
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w700,
),
)
: null),
actions: _buildActions(context, ref),
bottom: bottom,
);
}
List<Widget> _buildActions(BuildContext context, WidgetRef ref) {
final actions = <Widget>[];
// Extra screen-specific actions first
if (leadingActions != null) {
actions.addAll(leadingActions!);
}
if (showHome) {
actions.add(
IconButton(
onPressed: () => context.go(AppRoutes.homeAlias),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
);
}
if (showSearch) {
actions.add(
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const DiscoverScreen(),
fullscreenDialog: true,
),
),
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
);
}
if (showMessages) {
actions.add(
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true,
),
),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
);
}
actions.add(const SizedBox(width: 8));
return actions;
}
}

View file

@ -10,11 +10,9 @@ import '../../widgets/media/signed_media_image.dart';
import '../profile/viewable_profile_screen.dart';
import '../post/post_detail_screen.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../services/notification_service.dart';
import '../../providers/notification_provider.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import '../discover/discover_screen.dart';
import '../home/full_screen_shell.dart';
/// Notifications screen showing user activity
class NotificationsScreen extends ConsumerStatefulWidget {
@ -358,67 +356,8 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
backgroundColor: AppTheme.scaffoldBg,
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
title: Text(
'Activity',
style: GoogleFonts.inter(
color: AppTheme.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).pop();
// Small delay so pop completes before navigating
},
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const DiscoverScreen()),
);
},
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true,
),
),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
const SizedBox(width: 8),
],
child: FullScreenShell(
titleText: 'Activity',
bottom: TabBar(
onTap: (index) {
if (index != _activeTabIndex) {
@ -436,7 +375,6 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
Tab(text: 'Archived'),
],
),
),
body: _error != null
? _ErrorState(
message: _error!,

View file

@ -1,7 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/app_routes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/settings_provider.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago;
@ -13,13 +14,11 @@ import '../../theme/app_theme.dart';
import '../../widgets/post/interactive_reply_block.dart';
import '../../widgets/media/signed_media_image.dart';
import '../compose/compose_screen.dart';
import '../discover/discover_screen.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import '../../services/notification_service.dart';
import '../../widgets/post/post_body.dart';
import '../../widgets/post/post_view_mode.dart';
import '../../widgets/post/post_media.dart';
import '../../providers/notification_provider.dart';
import '../home/full_screen_shell.dart';
import 'package:share_plus/share_plus.dart';
@ -48,6 +47,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
final Map<String, Map<String, List<String>>> _reactionUsersByPost = {};
final Map<String, bool> _likedByPost = {};
final Map<String, bool> _savedByPost = {};
final Set<String> _nsfwRevealed = {};
bool _shouldBlurPost(Post post) {
if (!post.isNsfw || _nsfwRevealed.contains(post.id)) return false;
final settings = ref.read(settingsProvider);
return settings.user?.nsfwBlurEnabled ?? true;
}
late AnimationController _slideController;
late AnimationController _fadeController;
@ -152,29 +158,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: _buildAppBar(),
body: _buildBody(),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
backgroundColor: AppTheme.scaffoldBg,
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
// Fallback for direct links
context.go(AppRoutes.homeAlias);
}
},
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
return FullScreenShell(
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Text(
@ -187,41 +171,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
),
),
),
actions: [
IconButton(
onPressed: () => context.go(AppRoutes.homeAlias),
icon: Icon(Icons.home_outlined, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const DiscoverScreen(),
fullscreenDialog: true,
),
),
icon: Icon(Icons.search, color: AppTheme.navyBlue),
),
IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (_) => const SecureChatFullScreen(),
fullscreenDialog: true,
),
),
icon: Consumer(
builder: (context, ref, child) {
final badge = ref.watch(currentBadgeProvider);
return Badge(
label: Text(badge.messageCount.toString()),
isLabelVisible: badge.messageCount > 0,
backgroundColor: AppTheme.brightNavy,
child: Icon(Icons.chat_bubble_outline, color: AppTheme.navyBlue),
);
},
),
),
const SizedBox(width: 8),
],
body: _buildBody(),
);
}
@ -421,6 +371,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
),
),
const SizedBox(height: 3),
if (_shouldBlurPost(parentPost))
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Text(
parentPost.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
color: AppTheme.navyText.withValues(alpha: 0.8),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
)
else
Text(
parentPost.body,
maxLines: 2,
@ -496,6 +461,45 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
const SizedBox(height: 4),
_buildStageHeader(focalPost),
const SizedBox(height: 16),
if (_shouldBlurPost(focalPost)) ...[
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: _buildStageContent(focalPost),
),
),
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
const SizedBox(height: 16),
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: PostMedia(post: focalPost, mode: PostViewMode.detail),
),
),
],
const SizedBox(height: 12),
GestureDetector(
onTap: () => setState(() => _nsfwRevealed.add(focalPost.id)),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.amber.shade800.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.shade700.withOpacity(0.3)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.visibility_off, size: 18, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text('Sensitive Content — Tap to reveal',
style: TextStyle(color: Colors.amber.shade700, fontWeight: FontWeight.w600, fontSize: 13)),
],
),
),
),
] else ...[
_buildStageContent(focalPost),
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
const SizedBox(height: 16),
@ -504,6 +508,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
mode: PostViewMode.detail,
),
],
],
const SizedBox(height: 20),
_buildStageActions(focalPost),
@ -914,6 +919,11 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
const SizedBox(height: 8),
// Reply content
Expanded(
child: _shouldBlurPost(post)
? GestureDetector(
onTap: () => setState(() => _nsfwRevealed.add(post.id)),
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Text(
post.body,
style: GoogleFonts.inter(
@ -925,6 +935,18 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
overflow: TextOverflow.ellipsis,
),
),
)
: Text(
post.body,
style: GoogleFonts.inter(
color: AppTheme.navyText,
fontSize: 11,
height: 1.3,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
// Reply actions
Row(

View file

@ -43,6 +43,7 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
final ImageUploadService _imageUploadService = ImageUploadService();
bool _isAvatarUploading = false;
bool _isBannerUploading = false;
bool _nsfwSectionExpanded = false;
@override
Widget build(BuildContext context) {
@ -166,14 +167,14 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
],
),
const SizedBox(height: AppTheme.spacingLg),
_buildNsfwSection(state),
const SizedBox(height: AppTheme.spacingLg * 2),
_buildLogoutButton(),
const SizedBox(height: AppTheme.spacingLg),
_buildFooter(),
const SizedBox(height: AppTheme.spacingLg * 2),
_buildNsfwSection(state),
],
),
),
@ -430,11 +431,54 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
isUnder18 = age < 18;
}
return Container(
return Column(
children: [
// Expandable trigger subtle, at the very bottom
GestureDetector(
onTap: () => setState(() => _nsfwSectionExpanded = !_nsfwSectionExpanded),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
decoration: BoxDecoration(
color: AppTheme.cardSurface.withOpacity(0.6),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.shield_outlined,
size: 16,
color: AppTheme.textSecondary.withOpacity(0.5),
),
const SizedBox(width: 8),
Text(
'Content Sensitivity Settings',
style: AppTheme.textTheme.labelSmall?.copyWith(
color: AppTheme.textSecondary.withOpacity(0.5),
fontSize: 12,
),
),
const SizedBox(width: 4),
Icon(
_nsfwSectionExpanded ? Icons.expand_less : Icons.expand_more,
size: 16,
color: AppTheme.textSecondary.withOpacity(0.5),
),
],
),
),
),
// Expandable content
if (_nsfwSectionExpanded) ...[
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.15)),
border: Border.all(color: Colors.amber.shade700.withOpacity(0.25)),
),
padding: const EdgeInsets.all(20),
child: Column(
@ -465,9 +509,223 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
activeColor: Colors.amber.shade700,
onChanged: isUnder18
? null
: (v) => ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwEnabled: v),
: (v) {
if (v) {
_showEnableNsfwConfirmation(userSettings);
} else {
ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwEnabled: false, nsfwBlurEnabled: true),
);
}
},
),
if (userSettings.nsfwEnabled) ...[
const Divider(height: 1),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Blur Sensitive Content'),
subtitle: const Text(
'When enabled, NSFW posts are blurred until you tap to reveal. Disable to show them without blur.',
),
value: userSettings.nsfwBlurEnabled,
activeColor: Colors.amber.shade700,
onChanged: (v) {
if (!v) {
_showDisableBlurConfirmation(userSettings);
} else {
ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwBlurEnabled: true),
);
}
},
),
],
],
),
),
],
const SizedBox(height: AppTheme.spacingLg),
],
);
}
Future<void> _showEnableNsfwConfirmation(dynamic userSettings) async {
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade700, size: 28),
const SizedBox(width: 10),
const Expanded(child: Text('Enable Sensitive Content')),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade900.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade700.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.eighteen_up_rating, color: Colors.red.shade700, size: 24),
const SizedBox(width: 10),
Expanded(
child: Text(
'You must be 18 or older to enable this feature.',
style: TextStyle(
color: Colors.red.shade700,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'By enabling NSFW content you acknowledge:',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
const SizedBox(height: 10),
_buildBulletPoint('You will see content that may include nudity, violence, or mature themes from people you follow.'),
_buildBulletPoint('This is NOT a "free for all" — all content is AI-moderated. Hardcore pornography, extreme violence, and illegal content are never permitted.'),
_buildBulletPoint('Repeatedly posting improperly labeled content will result in warnings and potential account action.'),
_buildBulletPoint('Blur will be enabled by default. You can disable it separately.'),
_buildBulletPoint('NSFW content will only appear from accounts you follow — never in search, trending, or recommendations.'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Cancel', style: TextStyle(color: AppTheme.textSecondary)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber.shade700,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('I\'m 18+ — Enable'),
),
],
),
);
if (confirmed == true) {
ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwEnabled: true, nsfwBlurEnabled: true),
);
}
}
Future<void> _showDisableBlurConfirmation(dynamic userSettings) async {
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppTheme.cardSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Row(
children: [
Icon(Icons.visibility_outlined, color: Colors.amber.shade700, size: 28),
const SizedBox(width: 10),
const Expanded(child: Text('Disable Content Blur')),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade900.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade700.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.eighteen_up_rating, color: Colors.red.shade700, size: 24),
const SizedBox(width: 10),
Expanded(
child: Text(
'You must be 18 or older. This cannot be undone without re-enabling blur.',
style: TextStyle(
color: Colors.red.shade700,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'Disabling blur means sensitive content will be shown without any overlay or warning. This includes:',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 10),
_buildBulletPoint('Nudity, suggestive imagery, and mature themes will display fully visible in your feed.'),
_buildBulletPoint('Violence, blood, and graphic content (rated 5 and under) will appear unblurred.'),
_buildBulletPoint('You can re-enable blur at any time from this settings page.'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('Keep Blur On', style: TextStyle(color: AppTheme.textSecondary)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('I Understand — Disable Blur'),
),
],
),
);
if (confirmed == true) {
ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwBlurEnabled: false),
);
}
}
Widget _buildBulletPoint(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
width: 5, height: 5,
decoration: BoxDecoration(
color: Colors.amber.shade700,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: const TextStyle(fontSize: 12.5, height: 1.4)),
),
],
),

View file

@ -6,6 +6,7 @@ import '../../services/secure_chat_service.dart';
import '../../services/local_key_backup_service.dart';
import '../../theme/app_theme.dart';
import '../../widgets/media/signed_media_image.dart';
import '../home/full_screen_shell.dart';
import 'secure_chat_screen.dart';
import 'new_conversation_sheet.dart';
@ -114,12 +115,7 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
backgroundColor: AppTheme.scaffoldBg,
elevation: 0,
surfaceTintColor: Colors.transparent,
return FullScreenShell(
title: Row(
children: [
Text(
@ -148,7 +144,9 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
),
],
),
actions: [
showSearch: false,
showMessages: false,
leadingActions: [
IconButton(
onPressed: _loadConversations,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
@ -181,7 +179,6 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
tooltip: 'Upload encryption keys',
),
],
),
body: _buildBody(),
bottomNavigationBar: Container(
decoration: BoxDecoration(

View file

@ -555,6 +555,8 @@ class ApiService {
double? lat,
double? long,
bool userWarned = false,
bool isNsfw = false,
String? nsfwReason,
}) async {
// Validate and sanitize inputs
if (body.isEmpty) {
@ -606,6 +608,8 @@ class ApiService {
if (lat != null) 'beacon_lat': lat,
if (long != null) 'beacon_long': long,
if (userWarned) 'user_warned': true,
if (isNsfw) 'is_nsfw': true,
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
},
requireSignature: true,
);

View file

@ -127,6 +127,7 @@ class AppTheme {
colorScheme: _buildColorScheme(brand),
textTheme: textTheme,
fontFamily: GoogleFonts.literata().fontFamily,
fontFamilyFallback: [GoogleFonts.notoColorEmoji().fontFamily!],
appBarTheme: _buildAppBarTheme(brand, textTheme, lines),
cardTheme: _buildCardTheme(brand, lines),
elevatedButtonTheme: _buildElevatedButtonTheme(brand),

View file

@ -7,10 +7,12 @@ class ComposerToolbar extends StatelessWidget {
final VoidCallback onToggleBold;
final VoidCallback onToggleItalic;
final VoidCallback onToggleChain;
final VoidCallback? onToggleNsfw;
final VoidCallback? onSelectTtl;
final bool isBold;
final bool isItalic;
final bool allowChain;
final bool isNsfw;
final bool ttlOverrideActive;
final String? ttlLabel;
final int characterCount;
@ -24,10 +26,12 @@ class ComposerToolbar extends StatelessWidget {
required this.onToggleBold,
required this.onToggleItalic,
required this.onToggleChain,
this.onToggleNsfw,
this.onSelectTtl,
this.isBold = false,
this.isItalic = false,
this.allowChain = true,
this.isNsfw = false,
this.ttlOverrideActive = false,
this.ttlLabel,
this.characterCount = 0,
@ -93,6 +97,15 @@ class ComposerToolbar extends StatelessWidget {
),
tooltip: allowChain ? 'Allow chain' : 'Chain disabled',
),
if (onToggleNsfw != null)
IconButton(
onPressed: onToggleNsfw,
icon: Icon(
Icons.visibility_off_outlined,
color: isNsfw ? Colors.amber.shade700 : AppTheme.navyText.withValues(alpha: 0.4),
),
tooltip: isNsfw ? 'Marked as NSFW' : 'Mark as NSFW',
),
if (onSelectTtl != null)
IconButton(
onPressed: onSelectTtl,

View file

@ -1,6 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../models/post.dart';
import '../providers/settings_provider.dart';
import '../theme/app_theme.dart';
import 'post/post_actions.dart';
@ -12,7 +15,7 @@ import 'post/post_view_mode.dart';
import 'chain_quote_widget.dart';
import '../routes/app_routes.dart';
import 'modals/sanctuary_sheet.dart';
import '../theme/sojorn_feed_palette.dart';
/// Unified Post Card - Single Source of Truth for post display.
///
@ -35,7 +38,7 @@ import '../theme/sojorn_feed_palette.dart';
/// - Single source of truth for layout margins, padding, and elevation
/// - Pure stateless composition of sub-components
/// - ViewMode-driven visual variations without code duplication
class sojornPostCard extends StatefulWidget {
class sojornPostCard extends ConsumerStatefulWidget {
final Post post;
final PostViewMode mode;
final VoidCallback? onTap;
@ -58,10 +61,10 @@ class sojornPostCard extends StatefulWidget {
});
@override
State<sojornPostCard> createState() => _sojornPostCardState();
ConsumerState<sojornPostCard> createState() => _sojornPostCardState();
}
class _sojornPostCardState extends State<sojornPostCard> {
class _sojornPostCardState extends ConsumerState<sojornPostCard> {
bool _nsfwRevealed = false;
Post get post => widget.post;
@ -88,6 +91,14 @@ class _sojornPostCardState extends State<sojornPostCard> {
}
}
/// Whether to show NSFW blur overlay respects user's blur preference
bool get _shouldBlurNsfw {
if (!post.isNsfw || _nsfwRevealed) return false;
final settings = ref.read(settingsProvider);
final blurEnabled = settings.user?.nsfwBlurEnabled ?? true;
return blurEnabled;
}
double get _avatarSize {
switch (mode) {
case PostViewMode.feed:
@ -142,6 +153,15 @@ class _sojornPostCardState extends State<sojornPostCard> {
const SizedBox(height: AppTheme.spacingSm),
],
// Feed chain hint subtle "replying to" for non-thread views
if (!isThreadView && post.chainParent != null) ...[
GestureDetector(
onTap: onTap,
child: _ChainReplyHint(parent: post.chainParent!),
),
const SizedBox(height: 6),
],
// Main Post Content
const SizedBox(height: 4),
// Header row with menu - only header is clickable for profile
@ -195,7 +215,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
const SizedBox(height: 16),
// Body text - clickable for post detail with full background coverage
if (post.isNsfw && !_nsfwRevealed) ...[
if (_shouldBlurNsfw) ...[
// NSFW blurred body
ClipRect(
child: Stack(
@ -241,7 +261,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
const SizedBox(height: 12),
if (post.isNsfw && !_nsfwRevealed) ...[
if (_shouldBlurNsfw) ...[
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
@ -262,7 +282,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
],
// NSFW warning banner with tap-to-reveal
if (post.isNsfw && !_nsfwRevealed) ...[
if (_shouldBlurNsfw) ...[
GestureDetector(
onTap: () => setState(() => _nsfwRevealed = true),
child: Container(
@ -340,3 +360,68 @@ class _sojornPostCardState extends State<sojornPostCard> {
);
}
}
/// Subtle single-line "replying to" hint shown on feed cards that are chains.
/// Uses a thin left accent bar and muted text to stay unobtrusive.
class _ChainReplyHint extends StatelessWidget {
final PostPreview parent;
const _ChainReplyHint({required this.parent});
@override
Widget build(BuildContext context) {
final handle = parent.author?.handle ?? 'someone';
final snippet = parent.body
.replaceAll('\n', ' ')
.trim();
final truncated = snippet.length > 60
? '${snippet.substring(0, 60)}'
: snippet;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: AppTheme.egyptianBlue.withValues(alpha: 0.35),
width: 2.5,
),
),
),
child: Row(
children: [
Icon(
Icons.reply_rounded,
size: 13,
color: AppTheme.textTertiary,
),
const SizedBox(width: 5),
Text(
'@$handle',
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.egyptianBlue.withValues(alpha: 0.7),
),
),
if (truncated.isNotEmpty) ...[
const SizedBox(width: 6),
Expanded(
child: Text(
truncated,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppTheme.textTertiary,
fontStyle: FontStyle.italic,
),
),
),
],
],
),
);
}
}

View file

@ -45,12 +45,8 @@ if ($values.ContainsKey('TURNSTILE_SITE_KEY') -and -not [string]::IsNullOrWhiteS
}
Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green
Write-Host "Tip: press 'r' to hot reload, 'R' to hot restart, 'q' to quit" -ForegroundColor DarkGray
Write-Host ""
Push-Location $PSScriptRoot
try {
flutter run @defineArgs @Args
}
finally {
Pop-Location
}
Set-Location $PSScriptRoot
& flutter run @defineArgs @Args