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/secure_chat_service.dart';
import 'services/simple_e2ee_service.dart'; import 'services/simple_e2ee_service.dart';
import 'services/sync_manager.dart'; import 'services/sync_manager.dart';
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';
@ -32,6 +33,10 @@ void main() async {
} }
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
GoogleFonts.pendingFonts([
GoogleFonts.notoColorEmoji(),
]);
runApp( runApp(
const ProviderScope( const ProviderScope(
child: sojornApp(), child: sojornApp(),

View file

@ -10,6 +10,7 @@ class UserSettings {
final bool dataSaverMode; final bool dataSaverMode;
final int? defaultPostTtl; final int? defaultPostTtl;
final bool nsfwEnabled; final bool nsfwEnabled;
final bool nsfwBlurEnabled;
const UserSettings({ const UserSettings({
required this.userId, required this.userId,
@ -23,6 +24,7 @@ class UserSettings {
this.dataSaverMode = false, this.dataSaverMode = false,
this.defaultPostTtl, this.defaultPostTtl,
this.nsfwEnabled = false, this.nsfwEnabled = false,
this.nsfwBlurEnabled = true,
}); });
factory UserSettings.fromJson(Map<String, dynamic> json) { factory UserSettings.fromJson(Map<String, dynamic> json) {
@ -38,6 +40,7 @@ class UserSettings {
dataSaverMode: json['data_saver_mode'] as bool? ?? false, dataSaverMode: json['data_saver_mode'] as bool? ?? false,
defaultPostTtl: _parseIntervalHours(json['default_post_ttl']), defaultPostTtl: _parseIntervalHours(json['default_post_ttl']),
nsfwEnabled: json['nsfw_enabled'] as bool? ?? false, 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, 'data_saver_mode': dataSaverMode,
'default_post_ttl': defaultPostTtl, 'default_post_ttl': defaultPostTtl,
'nsfw_enabled': nsfwEnabled, 'nsfw_enabled': nsfwEnabled,
'nsfw_blur_enabled': nsfwBlurEnabled,
}; };
} }
@ -68,6 +72,7 @@ class UserSettings {
bool? dataSaverMode, bool? dataSaverMode,
int? defaultPostTtl, int? defaultPostTtl,
bool? nsfwEnabled, bool? nsfwEnabled,
bool? nsfwBlurEnabled,
}) { }) {
return UserSettings( return UserSettings(
userId: userId, userId: userId,
@ -81,6 +86,7 @@ class UserSettings {
dataSaverMode: dataSaverMode ?? this.dataSaverMode, dataSaverMode: dataSaverMode ?? this.dataSaverMode,
defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl, defaultPostTtl: defaultPostTtl ?? this.defaultPostTtl,
nsfwEnabled: nsfwEnabled ?? this.nsfwEnabled, 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 { void _launchUrl(String url) async {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@ -315,42 +384,48 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
const SizedBox(height: AppTheme.spacingSm), const SizedBox(height: AppTheme.spacingSm),
Row( Row(
children: [ children: [
// Month dropdown // Month picker
Expanded( Expanded(
flex: 3, flex: 3,
child: DropdownButtonFormField<int>( child: GestureDetector(
value: _birthMonth, onTap: () => _showMonthPicker(),
decoration: const InputDecoration( child: AbsorbPointer(
labelText: 'Month', child: TextFormField(
prefixIcon: Icon(Icons.calendar_month), decoration: InputDecoration(
labelText: '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), const SizedBox(width: AppTheme.spacingSm),
// Year dropdown // Year picker
Expanded( Expanded(
flex: 2, flex: 2,
child: DropdownButtonFormField<int>( child: GestureDetector(
value: _birthYear, onTap: () => _showYearPicker(),
decoration: const InputDecoration( child: AbsorbPointer(
labelText: 'Year', 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; String? _blockedMessage;
final int _maxCharacters = 500; final int _maxCharacters = 500;
bool _allowChain = true; bool _allowChain = true;
bool _isNsfw = false;
bool _isBold = false; bool _isBold = false;
bool _isItalic = false; bool _isItalic = false;
int? _ttlHoursOverride; int? _ttlHoursOverride;
@ -397,6 +398,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
imageUrl: imageUrl, imageUrl: imageUrl,
ttlHours: _ttlHoursOverride, ttlHours: _ttlHoursOverride,
userWarned: userWarned, userWarned: userWarned,
isNsfw: _isNsfw,
); );
if (mounted) { if (mounted) {
@ -630,12 +632,14 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
onToggleBold: _toggleBold, onToggleBold: _toggleBold,
onToggleItalic: _toggleItalic, onToggleItalic: _toggleItalic,
onToggleChain: _toggleChain, onToggleChain: _toggleChain,
onToggleNsfw: () => setState(() => _isNsfw = !_isNsfw),
onSelectTtl: _openTtlSelector, onSelectTtl: _openTtlSelector,
ttlOverrideActive: _ttlHoursOverride != null, ttlOverrideActive: _ttlHoursOverride != null,
ttlLabel: _ttlOptions[_ttlHoursOverride] ?? 'Use default', ttlLabel: _ttlOptions[_ttlHoursOverride] ?? 'Use default',
isBold: _isBold, isBold: _isBold,
isItalic: _isItalic, isItalic: _isItalic,
allowChain: _allowChain, allowChain: _allowChain,
isNsfw: _isNsfw,
characterCount: count, characterCount: count,
maxCharacters: _maxCharacters, maxCharacters: _maxCharacters,
isUploadingImage: _isUploadingImage, isUploadingImage: _isUploadingImage,
@ -875,10 +879,12 @@ class ComposeBottomBar extends StatelessWidget {
final VoidCallback onToggleBold; final VoidCallback onToggleBold;
final VoidCallback onToggleItalic; final VoidCallback onToggleItalic;
final VoidCallback onToggleChain; final VoidCallback onToggleChain;
final VoidCallback? onToggleNsfw;
final VoidCallback onSelectTtl; final VoidCallback onSelectTtl;
final bool isBold; final bool isBold;
final bool isItalic; final bool isItalic;
final bool allowChain; final bool allowChain;
final bool isNsfw;
final bool ttlOverrideActive; final bool ttlOverrideActive;
final String ttlLabel; final String ttlLabel;
final int characterCount; final int characterCount;
@ -892,12 +898,14 @@ class ComposeBottomBar extends StatelessWidget {
required this.onToggleBold, required this.onToggleBold,
required this.onToggleItalic, required this.onToggleItalic,
required this.onToggleChain, required this.onToggleChain,
this.onToggleNsfw,
required this.onSelectTtl, required this.onSelectTtl,
required this.ttlOverrideActive, required this.ttlOverrideActive,
required this.ttlLabel, required this.ttlLabel,
required this.isBold, required this.isBold,
required this.isItalic, required this.isItalic,
required this.allowChain, required this.allowChain,
this.isNsfw = false,
required this.characterCount, required this.characterCount,
required this.maxCharacters, required this.maxCharacters,
required this.isUploadingImage, required this.isUploadingImage,
@ -926,12 +934,14 @@ class ComposeBottomBar extends StatelessWidget {
onToggleBold: onToggleBold, onToggleBold: onToggleBold,
onToggleItalic: onToggleItalic, onToggleItalic: onToggleItalic,
onToggleChain: onToggleChain, onToggleChain: onToggleChain,
onToggleNsfw: onToggleNsfw,
onSelectTtl: onSelectTtl, onSelectTtl: onSelectTtl,
ttlOverrideActive: ttlOverrideActive, ttlOverrideActive: ttlOverrideActive,
ttlLabel: ttlLabel, ttlLabel: ttlLabel,
isBold: isBold, isBold: isBold,
isItalic: isItalic, isItalic: isItalic,
allowChain: allowChain, allowChain: allowChain,
isNsfw: isNsfw,
characterCount: characterCount, characterCount: characterCount,
maxCharacters: maxCharacters, maxCharacters: maxCharacters,
isUploadingImage: isUploadingImage, isUploadingImage: isUploadingImage,

View file

@ -13,10 +13,7 @@ import '../profile/viewable_profile_screen.dart';
import '../compose/compose_screen.dart'; import '../compose/compose_screen.dart';
import '../post/post_detail_screen.dart'; import '../post/post_detail_screen.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:go_router/go_router.dart'; import '../home/full_screen_shell.dart';
import 'package:google_fonts/google_fonts.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import '../../providers/notification_provider.dart';
/// Model for discover page data /// Model for discover page data
class DiscoverData { class DiscoverData {
@ -280,57 +277,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return FullScreenShell(
backgroundColor: AppTheme.scaffoldBg, titleText: 'Search',
appBar: AppBar( showSearch: false,
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),
],
),
body: Column( body: Column(
children: [ children: [
_buildHeader(), _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 '../profile/viewable_profile_screen.dart';
import '../post/post_detail_screen.dart'; import '../post/post_detail_screen.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../services/notification_service.dart'; import '../../services/notification_service.dart';
import '../../providers/notification_provider.dart'; import '../../providers/notification_provider.dart';
import '../secure_chat/secure_chat_full_screen.dart'; import '../home/full_screen_shell.dart';
import '../discover/discover_screen.dart';
/// Notifications screen showing user activity /// Notifications screen showing user activity
class NotificationsScreen extends ConsumerStatefulWidget { class NotificationsScreen extends ConsumerStatefulWidget {
@ -358,84 +356,24 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
return DefaultTabController( return DefaultTabController(
length: 2, length: 2,
child: Scaffold( child: FullScreenShell(
backgroundColor: AppTheme.scaffoldBg, titleText: 'Activity',
appBar: AppBar( bottom: TabBar(
backgroundColor: AppTheme.scaffoldBg, onTap: (index) {
elevation: 0, if (index != _activeTabIndex) {
surfaceTintColor: Colors.transparent, setState(() {
leading: IconButton( _activeTabIndex = index;
onPressed: () { });
if (Navigator.of(context).canPop()) { _loadNotifications(refresh: true);
Navigator.of(context).pop(); }
} else { },
context.go('/home'); indicatorColor: AppTheme.egyptianBlue,
} labelColor: AppTheme.egyptianBlue,
}, unselectedLabelColor: AppTheme.egyptianBlue.withOpacity(0.5),
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue), tabs: const [
), Tab(text: 'Active'),
title: Text( Tab(text: 'Archived'),
'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),
], ],
bottom: TabBar(
onTap: (index) {
if (index != _activeTabIndex) {
setState(() {
_activeTabIndex = index;
});
_loadNotifications(refresh: true);
}
},
indicatorColor: AppTheme.egyptianBlue,
labelColor: AppTheme.egyptianBlue,
unselectedLabelColor: AppTheme.egyptianBlue.withOpacity(0.5),
tabs: const [
Tab(text: 'Active'),
Tab(text: 'Archived'),
],
),
), ),
body: _error != null body: _error != null
? _ErrorState( ? _ErrorState(

View file

@ -1,7 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/app_routes.dart'; import '../../routes/app_routes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/settings_provider.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:timeago/timeago.dart' as timeago; 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/post/interactive_reply_block.dart';
import '../../widgets/media/signed_media_image.dart'; import '../../widgets/media/signed_media_image.dart';
import '../compose/compose_screen.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 '../../services/notification_service.dart';
import '../../widgets/post/post_body.dart'; import '../../widgets/post/post_body.dart';
import '../../widgets/post/post_view_mode.dart'; import '../../widgets/post/post_view_mode.dart';
import '../../widgets/post/post_media.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'; 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, Map<String, List<String>>> _reactionUsersByPost = {};
final Map<String, bool> _likedByPost = {}; final Map<String, bool> _likedByPost = {};
final Map<String, bool> _savedByPost = {}; 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 _slideController;
late AnimationController _fadeController; late AnimationController _fadeController;
@ -152,29 +158,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return FullScreenShell(
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),
),
title: AnimatedSwitcher( title: AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
child: Text( child: Text(
@ -187,41 +171,7 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
), ),
), ),
), ),
actions: [ body: _buildBody(),
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),
],
); );
} }
@ -421,6 +371,21 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
), ),
), ),
const SizedBox(height: 3), 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( Text(
parentPost.body, parentPost.body,
maxLines: 2, maxLines: 2,
@ -496,13 +461,53 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
const SizedBox(height: 4), const SizedBox(height: 4),
_buildStageHeader(focalPost), _buildStageHeader(focalPost),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildStageContent(focalPost), if (_shouldBlurPost(focalPost)) ...[
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[ ClipRect(
const SizedBox(height: 16), child: ImageFiltered(
PostMedia( imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
post: focalPost, child: _buildStageContent(focalPost),
mode: PostViewMode.detail, ),
), ),
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),
PostMedia(
post: focalPost,
mode: PostViewMode.detail,
),
],
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
@ -914,16 +919,33 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
const SizedBox(height: 8), const SizedBox(height: 8),
// Reply content // Reply content
Expanded( Expanded(
child: Text( child: _shouldBlurPost(post)
post.body, ? GestureDetector(
style: GoogleFonts.inter( onTap: () => setState(() => _nsfwRevealed.add(post.id)),
color: AppTheme.navyText, child: ImageFiltered(
fontSize: 11, imageFilter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
height: 1.3, child: Text(
), post.body,
maxLines: 4, style: GoogleFonts.inter(
overflow: TextOverflow.ellipsis, color: AppTheme.navyText,
), fontSize: 11,
height: 1.3,
),
maxLines: 4,
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), const SizedBox(height: 8),
// Reply actions // Reply actions

View file

@ -43,6 +43,7 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
final ImageUploadService _imageUploadService = ImageUploadService(); final ImageUploadService _imageUploadService = ImageUploadService();
bool _isAvatarUploading = false; bool _isAvatarUploading = false;
bool _isBannerUploading = false; bool _isBannerUploading = false;
bool _nsfwSectionExpanded = false;
@override @override
Widget build(BuildContext context) { 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), const SizedBox(height: AppTheme.spacingLg * 2),
_buildLogoutButton(), _buildLogoutButton(),
const SizedBox(height: AppTheme.spacingLg), const SizedBox(height: AppTheme.spacingLg),
_buildFooter(), _buildFooter(),
const SizedBox(height: AppTheme.spacingLg * 2),
_buildNsfwSection(state),
], ],
), ),
), ),
@ -430,44 +431,301 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
isUnder18 = age < 18; isUnder18 = age < 18;
} }
return Container( return Column(
decoration: BoxDecoration( children: [
color: AppTheme.cardSurface, // Expandable trigger subtle, at the very bottom
borderRadius: BorderRadius.circular(20), GestureDetector(
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.15)), 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: Colors.amber.shade700.withOpacity(0.25)),
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.visibility_off_outlined, size: 20, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text('Content Filters', style: AppTheme.textTheme.headlineSmall),
],
),
const SizedBox(height: 4),
Text(
'Control what content appears in your feed',
style: AppTheme.textTheme.labelSmall?.copyWith(color: Colors.grey),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Show Sensitive Content (NSFW)'),
subtitle: Text(
isUnder18
? 'You must be at least 18 years old to enable this feature. This is required by law in most jurisdictions.'
: 'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.',
),
value: userSettings.nsfwEnabled,
activeColor: Colors.amber.shade700,
onChanged: isUnder18
? null
: (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'),
),
],
), ),
padding: const EdgeInsets.all(20), );
child: Column(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Padding(
children: [ padding: const EdgeInsets.only(top: 6),
Icon(Icons.visibility_off_outlined, size: 20, color: Colors.amber.shade700), child: Container(
const SizedBox(width: 8), width: 5, height: 5,
Text('Content Filters', style: AppTheme.textTheme.headlineSmall), decoration: BoxDecoration(
], color: Colors.amber.shade700,
), shape: BoxShape.circle,
const SizedBox(height: 4), ),
Text(
'Control what content appears in your feed',
style: AppTheme.textTheme.labelSmall?.copyWith(color: Colors.grey),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Show Sensitive Content (NSFW)'),
subtitle: Text(
isUnder18
? 'You must be at least 18 years old to enable this feature. This is required by law in most jurisdictions.'
: 'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.',
), ),
value: userSettings.nsfwEnabled, ),
activeColor: Colors.amber.shade700, const SizedBox(width: 8),
onChanged: isUnder18 Expanded(
? null child: Text(text, style: const TextStyle(fontSize: 12.5, height: 1.4)),
: (v) => ref.read(settingsProvider.notifier).updateUser(
userSettings.copyWith(nsfwEnabled: v),
),
), ),
], ],
), ),

View file

@ -6,6 +6,7 @@ import '../../services/secure_chat_service.dart';
import '../../services/local_key_backup_service.dart'; import '../../services/local_key_backup_service.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 'secure_chat_screen.dart'; import 'secure_chat_screen.dart';
import 'new_conversation_sheet.dart'; import 'new_conversation_sheet.dart';
@ -114,74 +115,70 @@ class _SecureChatFullScreenState extends State<SecureChatFullScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return FullScreenShell(
backgroundColor: AppTheme.scaffoldBg, title: Row(
appBar: AppBar( children: [
backgroundColor: AppTheme.scaffoldBg, Text(
elevation: 0, 'Messages',
surfaceTintColor: Colors.transparent, style: GoogleFonts.literata(
title: Row( fontWeight: FontWeight.w600,
children: [ color: AppTheme.navyBlue,
Text( fontSize: 20,
'Messages',
style: GoogleFonts.literata(
fontWeight: FontWeight.w600,
color: AppTheme.navyBlue,
fontSize: 20,
),
), ),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'End-to-end encrypted',
style: GoogleFonts.inter(
color: AppTheme.brightNavy,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
actions: [
IconButton(
onPressed: _loadConversations,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
tooltip: 'Refresh conversations',
), ),
IconButton( const SizedBox(width: 8),
onPressed: () async { Container(
try { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
await _chatService.uploadKeysManually(); decoration: BoxDecoration(
if (mounted) { color: AppTheme.brightNavy.withValues(alpha: 0.1),
ScaffoldMessenger.of(context).showSnackBar( borderRadius: BorderRadius.circular(12),
SnackBar( ),
content: Text('Keys uploaded successfully'), child: Text(
backgroundColor: Colors.green, 'End-to-end encrypted',
), style: GoogleFonts.inter(
); color: AppTheme.brightNavy,
} fontSize: 10,
} catch (e) { fontWeight: FontWeight.w500,
if (mounted) { ),
ScaffoldMessenger.of(context).showSnackBar( ),
SnackBar(
content: Text('Failed to upload keys: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
icon: Icon(Icons.key, color: AppTheme.navyBlue),
tooltip: 'Upload encryption keys',
), ),
], ],
), ),
showSearch: false,
showMessages: false,
leadingActions: [
IconButton(
onPressed: _loadConversations,
icon: Icon(Icons.refresh, color: AppTheme.navyBlue),
tooltip: 'Refresh conversations',
),
IconButton(
onPressed: () async {
try {
await _chatService.uploadKeysManually();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Keys uploaded successfully'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to upload keys: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
icon: Icon(Icons.key, color: AppTheme.navyBlue),
tooltip: 'Upload encryption keys',
),
],
body: _buildBody(), body: _buildBody(),
bottomNavigationBar: Container( bottomNavigationBar: Container(
decoration: BoxDecoration( decoration: BoxDecoration(

View file

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

View file

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

View file

@ -7,10 +7,12 @@ class ComposerToolbar extends StatelessWidget {
final VoidCallback onToggleBold; final VoidCallback onToggleBold;
final VoidCallback onToggleItalic; final VoidCallback onToggleItalic;
final VoidCallback onToggleChain; final VoidCallback onToggleChain;
final VoidCallback? onToggleNsfw;
final VoidCallback? onSelectTtl; final VoidCallback? onSelectTtl;
final bool isBold; final bool isBold;
final bool isItalic; final bool isItalic;
final bool allowChain; final bool allowChain;
final bool isNsfw;
final bool ttlOverrideActive; final bool ttlOverrideActive;
final String? ttlLabel; final String? ttlLabel;
final int characterCount; final int characterCount;
@ -24,10 +26,12 @@ class ComposerToolbar extends StatelessWidget {
required this.onToggleBold, required this.onToggleBold,
required this.onToggleItalic, required this.onToggleItalic,
required this.onToggleChain, required this.onToggleChain,
this.onToggleNsfw,
this.onSelectTtl, this.onSelectTtl,
this.isBold = false, this.isBold = false,
this.isItalic = false, this.isItalic = false,
this.allowChain = true, this.allowChain = true,
this.isNsfw = false,
this.ttlOverrideActive = false, this.ttlOverrideActive = false,
this.ttlLabel, this.ttlLabel,
this.characterCount = 0, this.characterCount = 0,
@ -93,6 +97,15 @@ class ComposerToolbar extends StatelessWidget {
), ),
tooltip: allowChain ? 'Allow chain' : 'Chain disabled', 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) if (onSelectTtl != null)
IconButton( IconButton(
onPressed: onSelectTtl, onPressed: onSelectTtl,

View file

@ -1,6 +1,9 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../providers/settings_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'post/post_actions.dart'; import 'post/post_actions.dart';
@ -12,7 +15,7 @@ import 'post/post_view_mode.dart';
import 'chain_quote_widget.dart'; import 'chain_quote_widget.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import 'modals/sanctuary_sheet.dart'; import 'modals/sanctuary_sheet.dart';
import '../theme/sojorn_feed_palette.dart';
/// Unified Post Card - Single Source of Truth for post display. /// 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 /// - Single source of truth for layout margins, padding, and elevation
/// - Pure stateless composition of sub-components /// - Pure stateless composition of sub-components
/// - ViewMode-driven visual variations without code duplication /// - ViewMode-driven visual variations without code duplication
class sojornPostCard extends StatefulWidget { class sojornPostCard extends ConsumerStatefulWidget {
final Post post; final Post post;
final PostViewMode mode; final PostViewMode mode;
final VoidCallback? onTap; final VoidCallback? onTap;
@ -58,10 +61,10 @@ class sojornPostCard extends StatefulWidget {
}); });
@override @override
State<sojornPostCard> createState() => _sojornPostCardState(); ConsumerState<sojornPostCard> createState() => _sojornPostCardState();
} }
class _sojornPostCardState extends State<sojornPostCard> { class _sojornPostCardState extends ConsumerState<sojornPostCard> {
bool _nsfwRevealed = false; bool _nsfwRevealed = false;
Post get post => widget.post; 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 { double get _avatarSize {
switch (mode) { switch (mode) {
case PostViewMode.feed: case PostViewMode.feed:
@ -142,6 +153,15 @@ class _sojornPostCardState extends State<sojornPostCard> {
const SizedBox(height: AppTheme.spacingSm), 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 // Main Post Content
const SizedBox(height: 4), const SizedBox(height: 4),
// Header row with menu - only header is clickable for profile // Header row with menu - only header is clickable for profile
@ -195,7 +215,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Body text - clickable for post detail with full background coverage // Body text - clickable for post detail with full background coverage
if (post.isNsfw && !_nsfwRevealed) ...[ if (_shouldBlurNsfw) ...[
// NSFW blurred body // NSFW blurred body
ClipRect( ClipRect(
child: Stack( child: Stack(
@ -241,7 +261,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) || (post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[ (post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
if (post.isNsfw && !_nsfwRevealed) ...[ if (_shouldBlurNsfw) ...[
ClipRect( ClipRect(
child: ImageFiltered( child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
@ -262,7 +282,7 @@ class _sojornPostCardState extends State<sojornPostCard> {
], ],
// NSFW warning banner with tap-to-reveal // NSFW warning banner with tap-to-reveal
if (post.isNsfw && !_nsfwRevealed) ...[ if (_shouldBlurNsfw) ...[
GestureDetector( GestureDetector(
onTap: () => setState(() => _nsfwRevealed = true), onTap: () => setState(() => _nsfwRevealed = true),
child: Container( 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 "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 "" Write-Host ""
Push-Location $PSScriptRoot Set-Location $PSScriptRoot
try { & flutter run @defineArgs @Args
flutter run @defineArgs @Args
}
finally {
Pop-Location
}