diff --git a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart index 4a70a33..73a0fa6 100644 --- a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart +++ b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart @@ -8,7 +8,8 @@ import '../../models/post.dart'; import '../../theme/theme_extensions.dart'; import '../../theme/app_theme.dart'; import '../../widgets/post/sojorn_swipeable_post.dart'; -import '../../widgets/post/sponsored_post_card.dart'; +import '../../widgets/post/post_view_mode.dart'; +import '../../widgets/sojorn_post_card.dart'; import '../../services/ad_integration_service.dart'; import '../compose/compose_screen.dart'; import '../post/post_detail_screen.dart'; @@ -421,7 +422,10 @@ class _SponsoredPostSlide extends StatelessWidget { padding: const EdgeInsets.all(20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), - child: SponsoredPostCard(post: post), + child: sojornPostCard( + post: post, + mode: PostViewMode.sponsored, + ), ), ), ), diff --git a/sojorn_app/lib/theme/app_theme.dart b/sojorn_app/lib/theme/app_theme.dart index 6cead10..3f0bba3 100644 --- a/sojorn_app/lib/theme/app_theme.dart +++ b/sojorn_app/lib/theme/app_theme.dart @@ -41,10 +41,29 @@ class AppTheme { // Semantic static const Color error = SojornColors.error; + static const Color destructive = SojornColors.destructive; static Color get success => ksuPurple; static const Color warning = SojornColors.warning; static const Color info = SojornColors.info; + // NSFW / Sensitive + static const Color nsfwWarningBg = SojornColors.nsfwWarningBg; + static const Color nsfwWarningBorder = SojornColors.nsfwWarningBorder; + static const Color nsfwWarningIcon = SojornColors.nsfwWarningIcon; + static const Color nsfwWarningText = SojornColors.nsfwWarningText; + static const Color nsfwWarningSubText = SojornColors.nsfwWarningSubText; + static const Color nsfwRevealText = SojornColors.nsfwRevealText; + + // Sponsored / Ad + static const Color sponsoredBadgeBg = SojornColors.sponsoredBadgeBg; + static const Color sponsoredBadgeText = SojornColors.sponsoredBadgeText; + + // Overlays + static const Color overlayDark = SojornColors.overlayDark; + static const Color overlayLight = SojornColors.overlayLight; + static const Color mediaErrorBg = SojornColors.mediaErrorBg; + static const Color mediaLoadingBg = SojornColors.mediaLoadingBg; + // Trust Tiers static Color get tierEstablished => ext.trustTierColors.established; static Color get tierTrusted => ext.trustTierColors.trusted; diff --git a/sojorn_app/lib/theme/sojorn_feed_palette.dart b/sojorn_app/lib/theme/sojorn_feed_palette.dart deleted file mode 100644 index 24a6741..0000000 --- a/sojorn_app/lib/theme/sojorn_feed_palette.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'theme_extensions.dart'; - -@Deprecated('Use Theme.of(context).extension()!.feedPalettes instead.') -class sojornFeedPalette { - final Color backgroundTop; - final Color backgroundBottom; - final Color panelColor; - final Color textColor; - final Color subTextColor; - final Color accentColor; - - const sojornFeedPalette({ - required this.backgroundTop, - required this.backgroundBottom, - required this.panelColor, - required this.textColor, - required this.subTextColor, - required this.accentColor, - }); - - static const SojornFeedPalettes _palettes = SojornFeedPalettes.defaultPresets; - - static final List presets = List.unmodifiable( - _palettes.presets.map( - (palette) => sojornFeedPalette( - backgroundTop: palette.backgroundTop, - backgroundBottom: palette.backgroundBottom, - panelColor: palette.panelColor, - textColor: palette.textColor, - subTextColor: palette.subTextColor, - accentColor: palette.accentColor, - ), - ), - ); - - static sojornFeedPalette forId(String id) { - final palette = _palettes.forId(id); - return sojornFeedPalette( - backgroundTop: palette.backgroundTop, - backgroundBottom: palette.backgroundBottom, - panelColor: palette.panelColor, - textColor: palette.textColor, - subTextColor: palette.subTextColor, - accentColor: palette.accentColor, - ); - } -} diff --git a/sojorn_app/lib/theme/tokens.dart b/sojorn_app/lib/theme/tokens.dart index 17b142f..33a6330 100644 --- a/sojorn_app/lib/theme/tokens.dart +++ b/sojorn_app/lib/theme/tokens.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; class SojornColors { const SojornColors._(); - // Basic theme palette. + // ── Basic theme palette ────────────────────────────── static const Color basicNavyBlue = Color(0xFF000383); static const Color basicNavyText = Color(0xFF000383); static const Color basicEgyptianBlue = Color(0xFF0E38AE); @@ -14,7 +14,7 @@ class SojornColors { static const Color basicQueenPinkLight = Color(0xFFF9F2F7); static const Color basicWhite = Color(0xFFFFFFFF); - // Pop theme palette. + // ── Pop theme palette ──────────────────────────────── static const Color popNavyBlue = Color(0xFF000383); static const Color popNavyText = Color(0xFF0D1050); static const Color popEgyptianBlue = Color(0xFF0E38AE); @@ -25,19 +25,49 @@ class SojornColors { static const Color popCardSurface = Color(0xFFFFFFFF); static const Color popHighlight = Color(0xFFE5C0DD); - // Semantic colors. + // ── Semantic ───────────────────────────────────────── static const Color error = Color(0xFFD32F2F); + static const Color destructive = Color(0xFFD32F2F); static const Color warning = Color(0xFFFBC02D); static const Color info = Color(0xFF2196F3); static const Color textDisabled = Color(0xFF9E9E9E); static const Color textOnAccent = Color(0xFFFFFFFF); + // ── Post content ───────────────────────────────────── static const Color postContent = Color(0xFF1A1A1A); static const Color postContentLight = Color(0xFF4A4A4A); + + // ── Navigation ─────────────────────────────────────── static const Color bottomNavUnselected = Color(0xFF9EA3B0); + + // ── Trust tiers ────────────────────────────────────── static const Color tierNew = Color(0xFF9E9E9E); - // Feed palettes. + // ── NSFW / Sensitive content ───────────────────────── + static const Color nsfwWarningBg = Color(0x26EF6C00); // amber.800 @ 15% + static const Color nsfwWarningBorder = Color(0x4DF57C00); // amber.700 @ 30% + static const Color nsfwWarningIcon = Color(0xFFF57C00); // amber.700 + static const Color nsfwWarningText = Color(0xFFF57C00); // amber.700 + static const Color nsfwWarningSubText = Color(0xFFFB8C00); // amber.600 + static const Color nsfwRevealText = Color(0xCCFB8C00); // amber.600 @ 80% + + // ── Sponsored / Ad ─────────────────────────────────── + static const Color sponsoredBadgeBg = Color(0x1A7751A8); // royalPurple @ 10% + static const Color sponsoredBadgeText = Color(0xB37751A8); // royalPurple @ 70% + + // ── Overlays ───────────────────────────────────────── + static const Color overlayDark = Color(0x80000000); // black @ 50% + static const Color overlayLight = Color(0x4DFFFFFF); // white @ 30% + static const Color overlayScrim = Color(0x33000000); // black @ 20% + static const Color transparent = Color(0x00000000); + + // ── Surface states ─────────────────────────────────── + static const Color surfacePressed = Color(0x14000383); // navyBlue @ 8% + static const Color surfaceHover = Color(0x0A000383); // navyBlue @ 4% + static const Color mediaErrorBg = Color(0x4DD32F2F); // error @ 30% + static const Color mediaLoadingBg = Color(0x4DE5C0DD); // queenPink @ 30% + + // ── Feed palettes ──────────────────────────────────── static const Color feedNavyTop = Color(0xFF0B1023); static const Color feedNavyBottom = Color(0xFF1B2340); static const Color feedNavyPanel = Color(0xFF0E1328); @@ -84,9 +114,17 @@ class SojornSpacing { static const double lg = 24.0; static const double xl = 32.0; + // Granular steps for fine-tuning + static const double s6 = 6.0; + static const double s8 = 8.0; + static const double postShort = 16.0; static const double postMedium = 24.0; static const double postLong = 32.0; + + // Card-level margins + static const double cardGap = 16.0; + static const double cardGapThread = 4.0; } class SojornRadii { @@ -96,6 +134,7 @@ class SojornRadii { static const double sm = 4.0; static const double md = 8.0; static const double lg = 12.0; + static const double xl = 20.0; static const double full = 36.0; } diff --git a/sojorn_app/lib/widgets/category_tile.dart b/sojorn_app/lib/widgets/category_tile.dart deleted file mode 100644 index e1f7081..0000000 --- a/sojorn_app/lib/widgets/category_tile.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/category.dart'; -import '../theme/app_theme.dart'; - -class CategoryTile extends StatelessWidget { - final Category category; - final bool enabled; - final VoidCallback? onTap; - - const CategoryTile({ - super.key, - required this.category, - required this.enabled, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8.0), // Replaced AppTheme.radiusMd - child: Container( - padding: const EdgeInsets.all(AppTheme.spacingLg), - decoration: BoxDecoration( - color: enabled ? AppTheme.white : AppTheme.white.withOpacity(0.8), // Replaced surface and surfaceVariant - borderRadius: BorderRadius.circular(8.0), // Replaced AppTheme.radiusMd - border: Border.all( - color: enabled ? AppTheme.egyptianBlue : AppTheme.egyptianBlue.withOpacity(0.5), // Replaced border and borderSubtle - width: 1.0, // Replaced AppTheme.borderWidthThin - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.name, - style: AppTheme.textTheme.labelMedium?.copyWith( // Replaced AppTheme.labelLarge - color: AppTheme.navyText, // Ensure visibility - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppTheme.spacingSm), - Text( - category.description, - style: AppTheme.textTheme.bodyMedium?.copyWith( // Replaced AppTheme.bodySmall - color: AppTheme.navyText.withOpacity(0.8), // Replaced AppTheme.textSecondary - ), - ), - ], - ), - ), - ); - } -} \ No newline at end of file diff --git a/sojorn_app/lib/widgets/compose_fab.dart b/sojorn_app/lib/widgets/compose_fab.dart deleted file mode 100644 index 2d39fde..0000000 --- a/sojorn_app/lib/widgets/compose_fab.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import '../routes/app_routes.dart'; -import '../theme/app_theme.dart'; - -/// Floating action button for composing new posts -class ComposeFab extends StatelessWidget { - final String? heroTag; - - const ComposeFab({ - super.key, - this.heroTag, - }); - - @override - Widget build(BuildContext context) { - return FloatingActionButton( - heroTag: heroTag, - tooltip: 'Compose', - onPressed: () { - context.push(AppRoutes.quipCreate); - }, - backgroundColor: AppTheme.brightNavy, // Replaced AppTheme.accent - child: const Icon(Icons.edit_outlined, color: AppTheme.white), - ); - } -} diff --git a/sojorn_app/lib/widgets/composer_field.dart b/sojorn_app/lib/widgets/composer_field.dart deleted file mode 100644 index 73aaaa4..0000000 --- a/sojorn_app/lib/widgets/composer_field.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import '../theme/app_theme.dart'; - -class ComposerField extends StatelessWidget { - final TextEditingController controller; - final ValueChanged? onChanged; - final FormFieldValidator? validator; - final String? hintText; - final FocusNode? focusNode; - - const ComposerField({ - super.key, - required this.controller, - this.onChanged, - this.validator, - this.hintText, - this.focusNode, - }); - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - onChanged: onChanged, - validator: validator, - focusNode: focusNode, - maxLines: null, - minLines: 5, // Replaced AppTheme.composerMinLines - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: AppTheme.postBody, // Using AppTheme.postBody as it's a general body style - textAlignVertical: TextAlignVertical.top, - decoration: InputDecoration( - hintText: hintText, - // fillColor and contentPadding are now handled by AppTheme.inputDecorationTheme - // The InputDecorationTheme in AppTheme sets filled: true and fillColor: white, - // and also contentPadding: const EdgeInsets.all(16). - ), - ); - } -} \ No newline at end of file diff --git a/sojorn_app/lib/widgets/post/chain_quote_widget.dart b/sojorn_app/lib/widgets/post/chain_quote_widget.dart deleted file mode 100644 index 402c0e6..0000000 --- a/sojorn_app/lib/widgets/post/chain_quote_widget.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../models/post.dart'; -import '../../theme/app_theme.dart'; - -class ChainQuoteWidget extends StatelessWidget { - final PostPreview parent; - final VoidCallback onTap; - - const ChainQuoteWidget({ - super.key, - required this.parent, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: AppTheme.spacingMd), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(4.0), - highlightColor: Colors.transparent, - splashColor: AppTheme.queenPink.withValues(alpha: 0.3), - child: Container( - decoration: BoxDecoration( - color: AppTheme.queenPink.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4.0), - border: Border( - left: BorderSide( - color: AppTheme.egyptianBlue, - width: 3.5, - ), - ), - ), - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingSm, - AppTheme.spacingSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.subdirectory_arrow_right, - size: 14, - color: AppTheme.royalPurple, - ), - const SizedBox(width: 4), - Text( - '@${parent.author?.handle ?? 'unknown'}', - style: AppTheme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w600, - color: AppTheme.royalPurple, - ), - ), - const SizedBox(width: 6), - Text( - '· ${_formatTime(parent.createdAt)}', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.egyptianBlue, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - parent.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTheme.textTheme.bodyMedium?.copyWith( - color: AppTheme.navyText.withValues(alpha: 0.8), - height: 1.4, - ), - ), - ], - ), - ), - ), - ), - ); - } - - static String _formatTime(DateTime time) { - final now = DateTime.now(); - final diff = now.difference(time); - - if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - if (diff.inDays < 7) return '${diff.inDays}d'; - if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w'; - return '${(diff.inDays / 30).floor()}mo'; - } -} - -class ChainResponseWidget extends StatelessWidget { - final PostPreview post; - final VoidCallback onTap; - - const ChainResponseWidget({ - super.key, - required this.post, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - highlightColor: Colors.transparent, - splashColor: AppTheme.queenPink.withValues(alpha: 0.3), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingLg, - vertical: AppTheme.spacingSm, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 2, - height: 40, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration( - color: AppTheme.egyptianBlue.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(1), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - '@${post.author?.handle ?? 'unknown'}', - style: AppTheme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - ), - ), - const SizedBox(width: 6), - Text( - '· ${_formatTime(post.createdAt)}', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.egyptianBlue, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - post.body, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTheme.textTheme.bodyMedium?.copyWith( - color: AppTheme.navyText.withValues(alpha: 0.8), - height: 1.4, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - size: 18, - color: AppTheme.egyptianBlue, - ), - ], - ), - ), - ), - ); - } - - static String _formatTime(DateTime time) { - final now = DateTime.now(); - final diff = now.difference(time); - - if (diff.inMinutes < 60) return '${diff.inMinutes}m'; - if (diff.inHours < 24) return '${diff.inHours}h'; - if (diff.inDays < 7) return '${diff.inDays}d'; - if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w'; - return '${(diff.inDays / 30).floor()}mo'; - } -} diff --git a/sojorn_app/lib/widgets/post/post_actions.dart b/sojorn_app/lib/widgets/post/post_actions.dart index f5f36b4..214c7cf 100644 --- a/sojorn_app/lib/widgets/post/post_actions.dart +++ b/sojorn_app/lib/widgets/post/post_actions.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../models/post.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; import '../sojorn_snackbar.dart'; import '../reactions/reaction_picker.dart'; import '../reactions/reactions_display.dart'; @@ -284,7 +285,7 @@ class _PostActionsState extends ConsumerState { backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05), foregroundColor: AppTheme.navyBlue, elevation: 0, - shadowColor: Colors.transparent, + shadowColor: SojornColors.transparent, minimumSize: const Size(0, 44), padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder( diff --git a/sojorn_app/lib/widgets/post/post_body.dart b/sojorn_app/lib/widgets/post/post_body.dart index 05538f2..97970f9 100644 --- a/sojorn_app/lib/widgets/post/post_body.dart +++ b/sojorn_app/lib/widgets/post/post_body.dart @@ -46,6 +46,7 @@ class PostBody extends StatelessWidget { int? get _maxLines { switch (mode) { case PostViewMode.feed: + case PostViewMode.sponsored: return 12; // Truncate in feed case PostViewMode.detail: return null; // Show all in detail diff --git a/sojorn_app/lib/widgets/post/post_header.dart b/sojorn_app/lib/widgets/post/post_header.dart index a36c004..110b5f7 100644 --- a/sojorn_app/lib/widgets/post/post_header.dart +++ b/sojorn_app/lib/widgets/post/post_header.dart @@ -4,6 +4,7 @@ import '../../services/auth_service.dart'; import '../../models/post.dart'; import '../../providers/api_provider.dart'; import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; import '../../screens/profile/viewable_profile_screen.dart'; import 'package:timeago/timeago.dart' as timeago; import 'post_view_mode.dart'; @@ -174,7 +175,7 @@ class _PostHeaderState extends ConsumerState { child: Text( initial, style: AppTheme.textTheme.labelMedium?.copyWith( - color: Colors.white, + color: SojornColors.basicWhite, fontWeight: FontWeight.w600, fontSize: size * 0.4, ), diff --git a/sojorn_app/lib/widgets/post/post_link_preview.dart b/sojorn_app/lib/widgets/post/post_link_preview.dart index 92cccc0..7ce86c5 100644 --- a/sojorn_app/lib/widgets/post/post_link_preview.dart +++ b/sojorn_app/lib/widgets/post/post_link_preview.dart @@ -17,6 +17,7 @@ class PostLinkPreview extends StatelessWidget { double get _imageHeight { switch (mode) { case PostViewMode.feed: + case PostViewMode.sponsored: return 220.0; case PostViewMode.detail: return 280.0; diff --git a/sojorn_app/lib/widgets/post/post_media.dart b/sojorn_app/lib/widgets/post/post_media.dart index 1010118..291a695 100644 --- a/sojorn_app/lib/widgets/post/post_media.dart +++ b/sojorn_app/lib/widgets/post/post_media.dart @@ -4,6 +4,7 @@ import '../../models/post.dart'; import '../../routes/app_routes.dart'; import '../../theme/app_theme.dart'; +import '../../theme/tokens.dart'; import '../media/signed_media_image.dart'; import 'post_view_mode.dart'; @@ -32,6 +33,7 @@ class PostMedia extends StatelessWidget { double get _imageHeight { switch (mode) { case PostViewMode.feed: + case PostViewMode.sponsored: return 450.0; // Taller for better resolution/ratio case PostViewMode.detail: return 600.0; @@ -114,19 +116,19 @@ class PostMedia extends StatelessWidget { url: displayUrl, fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover, loadingBuilder: (context) => Container( - color: AppTheme.queenPink.withValues(alpha: 0.3), + color: AppTheme.mediaLoadingBg, child: const Center(child: CircularProgressIndicator()), ), errorBuilder: (context, error, stackTrace) => Container( - color: Colors.red.withValues(alpha: 0.3), + color: AppTheme.mediaErrorBg, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.broken_image, size: 48, color: Colors.white), + const Icon(Icons.broken_image, size: 48, color: SojornColors.basicWhite), const SizedBox(height: 8), Text('Error: $error', - style: const TextStyle(color: Colors.white, fontSize: 10)), + style: const TextStyle(color: SojornColors.basicWhite, fontSize: 10)), ], ), ), @@ -138,13 +140,13 @@ class PostMedia extends StatelessWidget { child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), + color: SojornColors.overlayDark, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + border: Border.all(color: SojornColors.basicWhite, width: 2), ), child: const Icon( Icons.play_arrow, - color: Colors.white, + color: SojornColors.basicWhite, size: 40, ), ), diff --git a/sojorn_app/lib/widgets/post/post_menu.dart b/sojorn_app/lib/widgets/post/post_menu.dart index b86d390..5178c70 100644 --- a/sojorn_app/lib/widgets/post/post_menu.dart +++ b/sojorn_app/lib/widgets/post/post_menu.dart @@ -5,6 +5,7 @@ import '../../services/auth_service.dart'; import '../../models/post.dart'; import '../../providers/api_provider.dart'; import '../../providers/feed_refresh_provider.dart'; +import '../../theme/tokens.dart'; import '../sojorn_snackbar.dart'; /// Post menu with kebab menu for owner actions (edit/delete). @@ -133,9 +134,9 @@ class _PostMenuState extends ConsumerState { child: const Text('Cancel'), ), ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + style: ElevatedButton.styleFrom(backgroundColor: SojornColors.destructive), onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete', style: TextStyle(color: Colors.white)), + child: const Text('Delete', style: TextStyle(color: SojornColors.basicWhite)), ), ], ), @@ -334,9 +335,9 @@ class _PostMenuState extends ConsumerState { value: 'delete', child: Row( children: [ - Icon(Icons.delete_outline, size: 20, color: Colors.red), + Icon(Icons.delete_outline, size: 20, color: SojornColors.destructive), SizedBox(width: 8), - Text('Delete', style: TextStyle(color: Colors.red)), + Text('Delete', style: TextStyle(color: SojornColors.destructive)), ], ), ), diff --git a/sojorn_app/lib/widgets/post/post_view_mode.dart b/sojorn_app/lib/widgets/post/post_view_mode.dart index d26817e..b8b1555 100644 --- a/sojorn_app/lib/widgets/post/post_view_mode.dart +++ b/sojorn_app/lib/widgets/post/post_view_mode.dart @@ -14,4 +14,7 @@ enum PostViewMode { /// Thread view - reduced padding, no card elevation, smaller avatars, /// connecting lines align correctly, media collapsed thread, + + /// Sponsored/ad view - same layout as feed with "Sponsored" badge in header + sponsored, } diff --git a/sojorn_app/lib/widgets/post/sponsored_post_card.dart b/sojorn_app/lib/widgets/post/sponsored_post_card.dart deleted file mode 100644 index 178d4a9..0000000 --- a/sojorn_app/lib/widgets/post/sponsored_post_card.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../models/post.dart'; -import '../../theme/app_theme.dart'; -import '../../utils/external_link_controller.dart'; -import '../media/signed_media_image.dart'; -import 'markdown_post_body.dart'; - -/// Widget for displaying a sponsored post (First-Party Contextual Ad) -/// -/// Design: Distinguishable from regular posts but not distracting. -/// - "Sponsored by [advertiserName]" header (small, uppercase, subtle) -/// - Subtle background tint (surfaceVariant) to legally distinguish from editorial -/// - Markdown body content -/// - Distinct CTA button (OutlinedButton with "Visit Site" icon) -class SponsoredPostCard extends StatelessWidget { - final Post post; - - const SponsoredPostCard({ - super.key, - required this.post, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingMd, - vertical: AppTheme.spacingSm, - ), - decoration: BoxDecoration( - border: Border.all( - color: AppTheme.royalPurple.withValues(alpha: 0.2), - width: 1, - ), - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - color: Theme.of(context).colorScheme.surfaceVariant.withValues(alpha: 0.5), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeader(), - if (post.imageUrl != null) _buildBannerImage(), - _buildContent(), - _buildCtaButton(context), - _buildAdDisclosure(), - ], - ), - ); - } - - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - 0, - ), - child: Row( - children: [ - Icon( - Icons.campaign, - size: 14, - color: AppTheme.royalPurple.withValues(alpha: 0.6), - ), - const SizedBox(width: AppTheme.spacingXs), - Text( - 'SPONSORED BY ${post.advertiserName?.toUpperCase() ?? 'ADVERTISER'}', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.royalPurple.withValues(alpha: 0.7), - fontWeight: FontWeight.w600, - fontSize: 10, - letterSpacing: 0.5, - ), - ), - ], - ), - ); - } - - Widget _buildBannerImage() { - return Padding( - padding: const EdgeInsets.only(top: AppTheme.spacingSm), - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(AppTheme.radiusMd - 1), - ), - child: SignedMediaImage( - url: post.imageUrl!, - height: 100, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const SizedBox.shrink(), - ), - ), - ); - } - - Widget _buildContent() { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingSm, - AppTheme.spacingMd, - AppTheme.spacingSm, - ), - child: MarkdownPostBody( - markdown: post.body, - maxLines: 4, - baseStyle: AppTheme.textTheme.bodyMedium?.copyWith( - color: AppTheme.navyText, - ), - ), - ); - } - - Widget _buildCtaButton(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingMd, - ), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _handleCtaTap(context), - icon: const Icon( - Icons.open_in_new, - size: 16, - ), - label: Text( - post.ctaText ?? 'Visit Site', - style: AppTheme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.royalPurple, - side: BorderSide( - color: AppTheme.royalPurple, - width: 1.5, - ), - padding: const EdgeInsets.symmetric( - horizontal: AppTheme.spacingMd, - vertical: AppTheme.spacingSm, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), - ), - ), - ), - ), - ); - } - - Widget _buildAdDisclosure() { - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB( - AppTheme.spacingMd, - AppTheme.spacingXs, - AppTheme.spacingMd, - AppTheme.spacingSm, - ), - decoration: BoxDecoration( - color: AppTheme.royalPurple.withValues(alpha: 0.03), - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(AppTheme.radiusMd - 1), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.info_outline, - size: 10, - color: AppTheme.navyText.withValues(alpha: 0.4), - ), - const SizedBox(width: AppTheme.spacingXs), - Text( - 'Sponsored · First-Party Contextual Content', - style: AppTheme.textTheme.labelSmall?.copyWith( - color: AppTheme.navyText.withValues(alpha: 0.4), - fontSize: 9, - ), - ), - ], - ), - ); - } - - Future _handleCtaTap(BuildContext context) async { - if (post.ctaLink != null) { - await ExternalLinkController.handleUrl(context, post.ctaLink!); - } - } -} diff --git a/sojorn_app/lib/widgets/post_with_video_widget.dart b/sojorn_app/lib/widgets/post_with_video_widget.dart deleted file mode 100644 index be77916..0000000 --- a/sojorn_app/lib/widgets/post_with_video_widget.dart +++ /dev/null @@ -1,292 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:timeago/timeago.dart' as timeago; -import '../models/post.dart'; -import '../theme/app_theme.dart'; -import 'media/signed_media_image.dart'; -import 'video_thumbnail_widget.dart'; -import 'post/post_actions.dart'; - -/// Enhanced post widget with video thumbnail support (Twitter-style) -class PostWithVideoWidget extends StatelessWidget { - final Post post; - final VoidCallback? onLike; - final VoidCallback? onComment; - final VoidCallback? onShare; - final VoidCallback? onVideoTap; - final bool showFullContent; - - const PostWithVideoWidget({ - super.key, - required this.post, - this.onLike, - this.onComment, - this.onShare, - this.onVideoTap, - this.showFullContent = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.cardSurface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppTheme.navyBlue.withValues(alpha: 0.1), - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Author header - _buildAuthorHeader(), - - const SizedBox(height: 12), - - // Post content - _buildPostContent(), - - // Video thumbnail (if present) - if (post.hasVideoContent == true) - VideoThumbnailWidget( - post: post, - onTap: onVideoTap, - ), - - // Regular image (if present and no video) - if (post.hasVideoContent != true && post.imageUrl != null) - _buildPostImage(), - - const SizedBox(height: 12), - - // Actions bar - _buildActionsBar(), - ], - ), - ), - ); - } - - Widget _buildAuthorHeader() { - return Row( - children: [ - // Avatar - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: post.author?.avatarUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(20), - child: SignedMediaImage( - url: post.author!.avatarUrl!, - width: 40, - height: 40, - fit: BoxFit.cover, - ), - ) - : Center( - child: Text( - post.author?.displayName?.isNotEmpty == true - ? post.author!.displayName![0].toUpperCase() - : post.author?.handle?.isNotEmpty == true - ? post.author!.handle![0].toUpperCase() - : '?', - style: GoogleFonts.inter( - color: AppTheme.brightNavy, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - ), - - const SizedBox(width: 12), - - // Author info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - post.author?.displayName ?? 'Anonymous', - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - color: AppTheme.navyBlue, - fontSize: 16, - ), - ), - if (post.author?.isOfficial == true) ...[ - const SizedBox(width: 4), - Icon( - Icons.verified, - size: 16, - color: AppTheme.brightNavy, - ), - ], - ], - ), - Row( - children: [ - Text( - '@${post.author?.handle ?? 'unknown'}', - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - Text( - '·', - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 14, - ), - ), - const SizedBox(width: 8), - Text( - timeago.format(post.createdAt), - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 14, - ), - ), - ], - ), - ], - ), - ), - - // More options - IconButton( - onPressed: () { - // TODO: Show post options - }, - icon: Icon( - Icons.more_horiz, - color: AppTheme.textDisabled, - ), - ), - ], - ); - } - - Widget _buildPostContent() { - return Text( - showFullContent ? post.body : _truncateText(post.body, 200), - style: GoogleFonts.inter( - color: AppTheme.textPrimary, - fontSize: 16, - height: 1.5, - ), - ); - } - - Widget _buildPostImage() { - return Container( - margin: const EdgeInsets.only(top: 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SignedMediaImage( - url: post.imageUrl!, - width: double.infinity, - height: 300, - fit: BoxFit.cover, - ), - ), - ); - } - - Widget _buildActionsBar() { - return Row( - children: [ - // Comments - InkWell( - onTap: onComment, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 18, - color: AppTheme.textDisabled, - ), - const SizedBox(width: 6), - Text( - (post.commentCount ?? 0).toString(), - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 14, - ), - ), - ], - ), - ), - ), - - const SizedBox(width: 16), - - // Likes - InkWell( - onTap: onLike, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - post.isLiked == true ? Icons.favorite : Icons.favorite_border, - size: 18, - color: post.isLiked == true ? Colors.red : AppTheme.textDisabled, - ), - const SizedBox(width: 6), - Text( - (post.likeCount ?? 0).toString(), - style: GoogleFonts.inter( - color: AppTheme.textDisabled, - fontSize: 14, - ), - ), - ], - ), - ), - ), - - const Spacer(), - - // Share - InkWell( - onTap: onShare, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Icon( - Icons.share, - size: 18, - color: AppTheme.textDisabled, - ), - ), - ), - ], - ); - } - - String _truncateText(String text, int maxLength) { - if (text.length <= maxLength) return text; - return '${text.substring(0, maxLength)}...'; - } -} diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index 7f09d7b..3342406 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -7,6 +7,7 @@ import '../models/post.dart'; import '../providers/settings_provider.dart'; import '../theme/app_theme.dart'; +import '../theme/tokens.dart'; import 'post/post_actions.dart'; import 'post/post_body.dart'; import 'post/post_header.dart'; @@ -82,6 +83,7 @@ class _sojornPostCardState extends ConsumerState { EdgeInsets get _padding { switch (mode) { case PostViewMode.feed: + case PostViewMode.sponsored: return const EdgeInsets.all(AppTheme.spacingMd); case PostViewMode.detail: return const EdgeInsets.all(AppTheme.spacingLg); @@ -117,6 +119,7 @@ class _sojornPostCardState extends ConsumerState { double get _avatarSize { switch (mode) { case PostViewMode.feed: + case PostViewMode.sponsored: return 36.0; case PostViewMode.detail: return 44.0; @@ -127,6 +130,8 @@ class _sojornPostCardState extends ConsumerState { } } + bool get _isSponsored => mode == PostViewMode.sponsored; + bool get _isThread => mode == PostViewMode.thread; bool get _effectiveThreadView => isThreadView || _isThread; @@ -136,7 +141,7 @@ class _sojornPostCardState extends ConsumerState { if (_shouldHideNsfw) return const SizedBox.shrink(); return Material( - color: Colors.transparent, + color: SojornColors.transparent, child: Container( margin: EdgeInsets.only(bottom: _isThread ? 4 : 16), decoration: BoxDecoration( @@ -238,7 +243,33 @@ class _sojornPostCardState extends ConsumerState { ), const SizedBox(height: 16), - + // Sponsored badge + if (_isSponsored) ...[ Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.sponsoredBadgeBg, + borderRadius: BorderRadius.circular(SojornRadii.md), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.campaign, size: 12, color: AppTheme.sponsoredBadgeText), + const SizedBox(width: 4), + Text( + 'SPONSORED${post.advertiserName != null ? ' BY ${post.advertiserName!.toUpperCase()}' : ''}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: AppTheme.sponsoredBadgeText, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + ], + // Body text - clickable for post detail with full background coverage if (_shouldBlurNsfw) ...[ // NSFW blurred body @@ -326,23 +357,23 @@ class _sojornPostCardState extends ConsumerState { margin: EdgeInsets.symmetric(horizontal: _padding.left, vertical: 8), 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)), + color: AppTheme.nsfwWarningBg, + borderRadius: BorderRadius.circular(SojornRadii.lg), + border: Border.all(color: AppTheme.nsfwWarningBorder), ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.visibility_off, size: 16, color: Colors.amber.shade700), + Icon(Icons.visibility_off, size: 16, color: AppTheme.nsfwWarningIcon), const SizedBox(width: 6), Text( 'Sensitive Content', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13, - color: Colors.amber.shade700, + color: AppTheme.nsfwWarningText, ), ), ], @@ -353,7 +384,7 @@ class _sojornPostCardState extends ConsumerState { post.nsfwReason!, style: TextStyle( fontSize: 11, - color: Colors.amber.shade600, + color: AppTheme.nsfwWarningSubText, ), ), ], @@ -363,7 +394,7 @@ class _sojornPostCardState extends ConsumerState { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Colors.amber.shade600.withOpacity(0.8), + color: AppTheme.nsfwRevealText, ), ), ], diff --git a/sojorn_app/lib/widgets/video_player_widget.dart b/sojorn_app/lib/widgets/video_player_widget.dart deleted file mode 100644 index e13c16c..0000000 --- a/sojorn_app/lib/widgets/video_player_widget.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; -import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; -import 'package:chewie/chewie.dart'; - -class VideoPlayerWidget extends StatefulWidget { - final Uint8List? videoBytes; - final String? filePath; - - const VideoPlayerWidget({ - super.key, - this.videoBytes, - this.filePath, - }) : assert(videoBytes != null || filePath != null); - - @override - State createState() => _VideoPlayerWidgetState(); -} - -class _VideoPlayerWidgetState extends State { - VideoPlayerController? _videoPlayerController; - ChewieController? _chewieController; - bool _isInitialized = false; - - @override - void initState() { - super.initState(); - _initializePlayer(); - } - - Future _initializePlayer() async { - try { - if (widget.videoBytes != null) { - _videoPlayerController = VideoPlayerController.contentUri( - Uri.dataFromBytes( - widget.videoBytes!, - mimeType: 'video/mp4', - ), - ); - } else if (widget.filePath != null) { - _videoPlayerController = VideoPlayerController.file(File(widget.filePath!)); - } - - if (_videoPlayerController != null) { - await _videoPlayerController!.initialize(); - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController!, - autoPlay: false, - looping: false, - showControls: true, - allowFullScreen: false, - allowMuting: true, - ); - - setState(() { - _isInitialized = true; - }); - } - } catch (e) { - } - } - - @override - void dispose() { - _videoPlayerController?.dispose(); - _chewieController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (!_isInitialized) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (_chewieController == null) { - return const Center( - child: Icon(Icons.videocam_off, color: Colors.white), - ); - } - - return Chewie( - controller: _chewieController!, - ); - } -} diff --git a/sojorn_app/lib/widgets/video_thumbnail_widget.dart b/sojorn_app/lib/widgets/video_thumbnail_widget.dart deleted file mode 100644 index 5fbe581..0000000 --- a/sojorn_app/lib/widgets/video_thumbnail_widget.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../models/post.dart'; -import '../theme/app_theme.dart'; -import 'media/signed_media_image.dart'; - -/// Widget for displaying video thumbnails on regular posts (Twitter-style) -/// Clicking opens the Quips feed with the full video -class VideoThumbnailWidget extends StatelessWidget { - final Post post; - final VoidCallback? onTap; - - const VideoThumbnailWidget({ - super.key, - required this.post, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - if (post.thumbnailUrl == null) return const SizedBox.shrink(); - - return GestureDetector( - onTap: onTap, - child: Container( - margin: const EdgeInsets.only(top: 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - // Video thumbnail - SignedMediaImage( - url: post.thumbnailUrl!, - width: double.infinity, - height: 200, - fit: BoxFit.cover, - ), - - // Dark overlay - Positioned.fill( - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.3), - ], - ), - ), - ), - ), - - // Play button overlay - Positioned.fill( - child: Center( - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: const Icon( - Icons.play_arrow, - color: Colors.black, - size: 28, - ), - ), - ), - ), - - // Duration indicator - if (post.durationMs != null) - Positioned( - bottom: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _formatDuration(post.durationMs!), - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - - // Video indicator badge - Positioned( - top: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.brightNavy, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'VIDEO', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - - String _formatDuration(int durationMs) { - final duration = Duration(milliseconds: durationMs); - final minutes = duration.inMinutes; - final seconds = duration.inSeconds % 60; - return '$minutes:${seconds.toString().padLeft(2, '0')}'; - } -}