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:
parent
8d00dc4fda
commit
bc35eea69b
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
labelText: 'Month',
|
||||
prefixIcon: Icon(Icons.calendar_month),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showMonthPicker(),
|
||||
child: AbsorbPointer(
|
||||
child: TextFormField(
|
||||
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),
|
||||
// Year dropdown
|
||||
// Year picker
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _birthYear,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Year',
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
158
sojorn_app/lib/screens/home/full_screen_shell.dart
Normal file
158
sojorn_app/lib/screens/home/full_screen_shell.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,84 +356,24 @@ 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) {
|
||||
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'),
|
||||
],
|
||||
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
|
||||
? _ErrorState(
|
||||
|
|
|
|||
|
|
@ -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,13 +461,53 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
const SizedBox(height: 4),
|
||||
_buildStageHeader(focalPost),
|
||||
const SizedBox(height: 16),
|
||||
_buildStageContent(focalPost),
|
||||
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
PostMedia(
|
||||
post: focalPost,
|
||||
mode: PostViewMode.detail,
|
||||
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),
|
||||
PostMedia(
|
||||
post: focalPost,
|
||||
mode: PostViewMode.detail,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
|
@ -914,16 +919,33 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
const SizedBox(height: 8),
|
||||
// Reply content
|
||||
Expanded(
|
||||
child: Text(
|
||||
post.body,
|
||||
style: GoogleFonts.inter(
|
||||
color: AppTheme.navyText,
|
||||
fontSize: 11,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
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(
|
||||
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),
|
||||
// Reply actions
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -419,7 +420,7 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
|||
Widget _buildNsfwSection(dynamic state) {
|
||||
final userSettings = state.user;
|
||||
if (userSettings == null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
// Calculate age from profile
|
||||
final profile = state.profile;
|
||||
bool isUnder18 = false;
|
||||
|
|
@ -429,45 +430,302 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
|||
if (now.month < profile.birthMonth) age--;
|
||||
isUnder18 = age < 18;
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.15)),
|
||||
|
||||
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: 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,
|
||||
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.',
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Container(
|
||||
width: 5, height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade700,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
value: userSettings.nsfwEnabled,
|
||||
activeColor: Colors.amber.shade700,
|
||||
onChanged: isUnder18
|
||||
? null
|
||||
: (v) => ref.read(settingsProvider.notifier).updateUser(
|
||||
userSettings.copyWith(nsfwEnabled: v),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text, style: const TextStyle(fontSize: 12.5, height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,74 +115,70 @@ 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,
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Messages',
|
||||
style: GoogleFonts.literata(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
fontSize: 20,
|
||||
),
|
||||
return FullScreenShell(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
'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(
|
||||
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',
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue