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/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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
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 '../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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue