diff --git a/sojorn_app/lib/main.dart b/sojorn_app/lib/main.dart index 2acbbcc..b3413c7 100644 --- a/sojorn_app/lib/main.dart +++ b/sojorn_app/lib/main.dart @@ -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(), diff --git a/sojorn_app/lib/models/user_settings.dart b/sojorn_app/lib/models/user_settings.dart index ed0610a..2204f7e 100644 --- a/sojorn_app/lib/models/user_settings.dart +++ b/sojorn_app/lib/models/user_settings.dart @@ -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 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, ); } diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 92a3e64..7bf7338 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -144,6 +144,75 @@ class _SignUpScreenState extends ConsumerState { } } + 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 { const SizedBox(height: AppTheme.spacingSm), Row( children: [ - // Month dropdown + // Month picker Expanded( flex: 3, - child: DropdownButtonFormField( - 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( - 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, ), ), ], diff --git a/sojorn_app/lib/screens/compose/compose_screen.dart b/sojorn_app/lib/screens/compose/compose_screen.dart index e688d32..aa6703c 100644 --- a/sojorn_app/lib/screens/compose/compose_screen.dart +++ b/sojorn_app/lib/screens/compose/compose_screen.dart @@ -42,6 +42,7 @@ class _ComposeScreenState extends ConsumerState { 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 { imageUrl: imageUrl, ttlHours: _ttlHoursOverride, userWarned: userWarned, + isNsfw: _isNsfw, ); if (mounted) { @@ -630,12 +632,14 @@ class _ComposeScreenState extends ConsumerState { 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, diff --git a/sojorn_app/lib/screens/discover/discover_screen.dart b/sojorn_app/lib/screens/discover/discover_screen.dart index 3e8dd71..ffc120c 100644 --- a/sojorn_app/lib/screens/discover/discover_screen.dart +++ b/sojorn_app/lib/screens/discover/discover_screen.dart @@ -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 { @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(), diff --git a/sojorn_app/lib/screens/home/full_screen_shell.dart b/sojorn_app/lib/screens/home/full_screen_shell.dart new file mode 100644 index 0000000..8c6e794 --- /dev/null +++ b/sojorn_app/lib/screens/home/full_screen_shell.dart @@ -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? 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 _buildActions(BuildContext context, WidgetRef ref) { + final actions = []; + + // 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; + } +} diff --git a/sojorn_app/lib/screens/notifications/notifications_screen.dart b/sojorn_app/lib/screens/notifications/notifications_screen.dart index a1d86aa..736d733 100644 --- a/sojorn_app/lib/screens/notifications/notifications_screen.dart +++ b/sojorn_app/lib/screens/notifications/notifications_screen.dart @@ -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 { 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( diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index 60eb298..6915c9f 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -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>> _reactionUsersByPost = {}; final Map _likedByPost = {}; final Map _savedByPost = {}; + final Set _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 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 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 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 diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart index e1db71d..61150ec 100644 --- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart @@ -43,6 +43,7 @@ class _ProfileSettingsScreenState extends ConsumerState { 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 { ], ), - 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 { 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 { 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 _showEnableNsfwConfirmation(dynamic userSettings) async { + final confirmed = await showDialog( + 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 _showDisableBlurConfirmation(dynamic userSettings) async { + final confirmed = await showDialog( + 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)), ), ], ), diff --git a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart index d1a1c98..99616d4 100644 --- a/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart +++ b/sojorn_app/lib/screens/secure_chat/secure_chat_full_screen.dart @@ -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 { @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( diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index b71540d..8a999a7 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -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, ); diff --git a/sojorn_app/lib/theme/app_theme.dart b/sojorn_app/lib/theme/app_theme.dart index 7d8caf6..6cead10 100644 --- a/sojorn_app/lib/theme/app_theme.dart +++ b/sojorn_app/lib/theme/app_theme.dart @@ -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), diff --git a/sojorn_app/lib/widgets/composer/composer_toolbar.dart b/sojorn_app/lib/widgets/composer/composer_toolbar.dart index 6359d93..e9817a3 100644 --- a/sojorn_app/lib/widgets/composer/composer_toolbar.dart +++ b/sojorn_app/lib/widgets/composer/composer_toolbar.dart @@ -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, diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index a29c31b..8f4e469 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -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 createState() => _sojornPostCardState(); + ConsumerState createState() => _sojornPostCardState(); } -class _sojornPostCardState extends State { +class _sojornPostCardState extends ConsumerState { bool _nsfwRevealed = false; Post get post => widget.post; @@ -88,6 +91,14 @@ class _sojornPostCardState extends State { } } + /// 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 { 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 { 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 { (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 { ], // 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 { ); } } + +/// 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, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/sojorn_app/run_dev.ps1 b/sojorn_app/run_dev.ps1 index fdb839c..4422b5d 100644 --- a/sojorn_app/run_dev.ps1 +++ b/sojorn_app/run_dev.ps1 @@ -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