Grand Unification: centralize design tokens, add sponsored PostViewMode, delete 9 dead files

Phase 1 - Design Tokens:
- tokens.dart: Add NSFW, sponsored, overlay, surface state color tokens
- tokens.dart: Add granular spacing (s6, s8) and card gap constants
- tokens.dart: Add SojornRadii.xl
- app_theme.dart: Bridge new tokens (nsfwWarning*, sponsored*, overlay*, media*)

Phase 2 - Super Post Unification:
- post_view_mode.dart: Add PostViewMode.sponsored enum value
- sojorn_post_card.dart: Handle sponsored mode with badge, use token refs for NSFW colors
- feed_sojorn_screen.dart: Replace SponsoredPostCard with sojornPostCard(mode: sponsored)
- post_body/media/link_preview: Add sponsored case to all PostViewMode switches
- post_media.dart: Replace inline Colors.* with SojornColors/AppTheme tokens
- post_header.dart: Replace Colors.white with SojornColors.basicWhite
- post_menu.dart: Replace Colors.red with SojornColors.destructive
- post_actions.dart: Replace Colors.transparent with SojornColors.transparent

Phase 3 - Tree Shake (9 files deleted):
- widgets/post/sponsored_post_card.dart (absorbed into sojornPostCard)
- widgets/post/chain_quote_widget.dart (duplicate, unused)
- widgets/post_with_video_widget.dart (dead)
- widgets/video_player_widget.dart (dead)
- widgets/video_thumbnail_widget.dart (dead)
- widgets/composer_field.dart (dead)
- widgets/compose_fab.dart (dead)
- widgets/category_tile.dart (dead)
- theme/sojorn_feed_palette.dart (deprecated, unused)
This commit is contained in:
Patrick Britton 2026-02-10 13:55:00 -06:00
parent b8e070d121
commit 4512ff11d1
20 changed files with 131 additions and 1107 deletions

View file

@ -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,
),
),
),
),

View file

@ -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;

View file

@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import 'theme_extensions.dart';
@Deprecated('Use Theme.of(context).extension<SojornExt>()!.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<sojornFeedPalette> presets = List<sojornFeedPalette>.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,
);
}
}

View file

@ -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;
}

View file

@ -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
),
),
],
),
),
);
}
}

View file

@ -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),
);
}
}

View file

@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class ComposerField extends StatelessWidget {
final TextEditingController controller;
final ValueChanged<String>? onChanged;
final FormFieldValidator<String>? 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).
),
);
}
}

View file

@ -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';
}
}

View file

@ -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<PostActions> {
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(

View file

@ -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

View file

@ -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<PostHeader> {
child: Text(
initial,
style: AppTheme.textTheme.labelMedium?.copyWith(
color: Colors.white,
color: SojornColors.basicWhite,
fontWeight: FontWeight.w600,
fontSize: size * 0.4,
),

View file

@ -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;

View file

@ -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,
),
),

View file

@ -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<PostMenu> {
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<PostMenu> {
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)),
],
),
),

View file

@ -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,
}

View file

@ -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<void> _handleCtaTap(BuildContext context) async {
if (post.ctaLink != null) {
await ExternalLinkController.handleUrl(context, post.ctaLink!);
}
}
}

View file

@ -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)}...';
}
}

View file

@ -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<sojornPostCard> {
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<sojornPostCard> {
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<sojornPostCard> {
}
}
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<sojornPostCard> {
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,6 +243,32 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
),
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) ...[
@ -326,23 +357,23 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
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<sojornPostCard> {
post.nsfwReason!,
style: TextStyle(
fontSize: 11,
color: Colors.amber.shade600,
color: AppTheme.nsfwWarningSubText,
),
),
],
@ -363,7 +394,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.amber.shade600.withOpacity(0.8),
color: AppTheme.nsfwRevealText,
),
),
],

View file

@ -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<VideoPlayerWidget> createState() => _VideoPlayerWidgetState();
}
class _VideoPlayerWidgetState extends State<VideoPlayerWidget> {
VideoPlayerController? _videoPlayerController;
ChewieController? _chewieController;
bool _isInitialized = false;
@override
void initState() {
super.initState();
_initializePlayer();
}
Future<void> _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!,
);
}
}

View file

@ -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')}';
}
}