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:
parent
b8e070d121
commit
4512ff11d1
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}...';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue