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/theme_extensions.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/post/sojorn_swipeable_post.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 '../../services/ad_integration_service.dart';
|
||||||
import '../compose/compose_screen.dart';
|
import '../compose/compose_screen.dart';
|
||||||
import '../post/post_detail_screen.dart';
|
import '../post/post_detail_screen.dart';
|
||||||
|
|
@ -421,7 +422,10 @@ class _SponsoredPostSlide extends StatelessWidget {
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
child: SponsoredPostCard(post: post),
|
child: sojornPostCard(
|
||||||
|
post: post,
|
||||||
|
mode: PostViewMode.sponsored,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,29 @@ class AppTheme {
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
static const Color error = SojornColors.error;
|
static const Color error = SojornColors.error;
|
||||||
|
static const Color destructive = SojornColors.destructive;
|
||||||
static Color get success => ksuPurple;
|
static Color get success => ksuPurple;
|
||||||
static const Color warning = SojornColors.warning;
|
static const Color warning = SojornColors.warning;
|
||||||
static const Color info = SojornColors.info;
|
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
|
// Trust Tiers
|
||||||
static Color get tierEstablished => ext.trustTierColors.established;
|
static Color get tierEstablished => ext.trustTierColors.established;
|
||||||
static Color get tierTrusted => ext.trustTierColors.trusted;
|
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 {
|
class SojornColors {
|
||||||
const SojornColors._();
|
const SojornColors._();
|
||||||
|
|
||||||
// Basic theme palette.
|
// ── Basic theme palette ──────────────────────────────
|
||||||
static const Color basicNavyBlue = Color(0xFF000383);
|
static const Color basicNavyBlue = Color(0xFF000383);
|
||||||
static const Color basicNavyText = Color(0xFF000383);
|
static const Color basicNavyText = Color(0xFF000383);
|
||||||
static const Color basicEgyptianBlue = Color(0xFF0E38AE);
|
static const Color basicEgyptianBlue = Color(0xFF0E38AE);
|
||||||
|
|
@ -14,7 +14,7 @@ class SojornColors {
|
||||||
static const Color basicQueenPinkLight = Color(0xFFF9F2F7);
|
static const Color basicQueenPinkLight = Color(0xFFF9F2F7);
|
||||||
static const Color basicWhite = Color(0xFFFFFFFF);
|
static const Color basicWhite = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
// Pop theme palette.
|
// ── Pop theme palette ────────────────────────────────
|
||||||
static const Color popNavyBlue = Color(0xFF000383);
|
static const Color popNavyBlue = Color(0xFF000383);
|
||||||
static const Color popNavyText = Color(0xFF0D1050);
|
static const Color popNavyText = Color(0xFF0D1050);
|
||||||
static const Color popEgyptianBlue = Color(0xFF0E38AE);
|
static const Color popEgyptianBlue = Color(0xFF0E38AE);
|
||||||
|
|
@ -25,19 +25,49 @@ class SojornColors {
|
||||||
static const Color popCardSurface = Color(0xFFFFFFFF);
|
static const Color popCardSurface = Color(0xFFFFFFFF);
|
||||||
static const Color popHighlight = Color(0xFFE5C0DD);
|
static const Color popHighlight = Color(0xFFE5C0DD);
|
||||||
|
|
||||||
// Semantic colors.
|
// ── Semantic ─────────────────────────────────────────
|
||||||
static const Color error = Color(0xFFD32F2F);
|
static const Color error = Color(0xFFD32F2F);
|
||||||
|
static const Color destructive = Color(0xFFD32F2F);
|
||||||
static const Color warning = Color(0xFFFBC02D);
|
static const Color warning = Color(0xFFFBC02D);
|
||||||
static const Color info = Color(0xFF2196F3);
|
static const Color info = Color(0xFF2196F3);
|
||||||
static const Color textDisabled = Color(0xFF9E9E9E);
|
static const Color textDisabled = Color(0xFF9E9E9E);
|
||||||
static const Color textOnAccent = Color(0xFFFFFFFF);
|
static const Color textOnAccent = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
|
// ── Post content ─────────────────────────────────────
|
||||||
static const Color postContent = Color(0xFF1A1A1A);
|
static const Color postContent = Color(0xFF1A1A1A);
|
||||||
static const Color postContentLight = Color(0xFF4A4A4A);
|
static const Color postContentLight = Color(0xFF4A4A4A);
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────────────────────────
|
||||||
static const Color bottomNavUnselected = Color(0xFF9EA3B0);
|
static const Color bottomNavUnselected = Color(0xFF9EA3B0);
|
||||||
|
|
||||||
|
// ── Trust tiers ──────────────────────────────────────
|
||||||
static const Color tierNew = Color(0xFF9E9E9E);
|
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 feedNavyTop = Color(0xFF0B1023);
|
||||||
static const Color feedNavyBottom = Color(0xFF1B2340);
|
static const Color feedNavyBottom = Color(0xFF1B2340);
|
||||||
static const Color feedNavyPanel = Color(0xFF0E1328);
|
static const Color feedNavyPanel = Color(0xFF0E1328);
|
||||||
|
|
@ -84,9 +114,17 @@ class SojornSpacing {
|
||||||
static const double lg = 24.0;
|
static const double lg = 24.0;
|
||||||
static const double xl = 32.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 postShort = 16.0;
|
||||||
static const double postMedium = 24.0;
|
static const double postMedium = 24.0;
|
||||||
static const double postLong = 32.0;
|
static const double postLong = 32.0;
|
||||||
|
|
||||||
|
// Card-level margins
|
||||||
|
static const double cardGap = 16.0;
|
||||||
|
static const double cardGapThread = 4.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SojornRadii {
|
class SojornRadii {
|
||||||
|
|
@ -96,6 +134,7 @@ class SojornRadii {
|
||||||
static const double sm = 4.0;
|
static const double sm = 4.0;
|
||||||
static const double md = 8.0;
|
static const double md = 8.0;
|
||||||
static const double lg = 12.0;
|
static const double lg = 12.0;
|
||||||
|
static const double xl = 20.0;
|
||||||
static const double full = 36.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 '../../models/post.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../../theme/tokens.dart';
|
||||||
import '../sojorn_snackbar.dart';
|
import '../sojorn_snackbar.dart';
|
||||||
import '../reactions/reaction_picker.dart';
|
import '../reactions/reaction_picker.dart';
|
||||||
import '../reactions/reactions_display.dart';
|
import '../reactions/reactions_display.dart';
|
||||||
|
|
@ -284,7 +285,7 @@ class _PostActionsState extends ConsumerState<PostActions> {
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.05),
|
||||||
foregroundColor: AppTheme.navyBlue,
|
foregroundColor: AppTheme.navyBlue,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: SojornColors.transparent,
|
||||||
minimumSize: const Size(0, 44),
|
minimumSize: const Size(0, 44),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class PostBody extends StatelessWidget {
|
||||||
int? get _maxLines {
|
int? get _maxLines {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
|
case PostViewMode.sponsored:
|
||||||
return 12; // Truncate in feed
|
return 12; // Truncate in feed
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return null; // Show all in detail
|
return null; // Show all in detail
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import '../../services/auth_service.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../../theme/tokens.dart';
|
||||||
import '../../screens/profile/viewable_profile_screen.dart';
|
import '../../screens/profile/viewable_profile_screen.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
import 'post_view_mode.dart';
|
import 'post_view_mode.dart';
|
||||||
|
|
@ -174,7 +175,7 @@ class _PostHeaderState extends ConsumerState<PostHeader> {
|
||||||
child: Text(
|
child: Text(
|
||||||
initial,
|
initial,
|
||||||
style: AppTheme.textTheme.labelMedium?.copyWith(
|
style: AppTheme.textTheme.labelMedium?.copyWith(
|
||||||
color: Colors.white,
|
color: SojornColors.basicWhite,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: size * 0.4,
|
fontSize: size * 0.4,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class PostLinkPreview extends StatelessWidget {
|
||||||
double get _imageHeight {
|
double get _imageHeight {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
|
case PostViewMode.sponsored:
|
||||||
return 220.0;
|
return 220.0;
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return 280.0;
|
return 280.0;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import '../../models/post.dart';
|
||||||
import '../../routes/app_routes.dart';
|
import '../../routes/app_routes.dart';
|
||||||
|
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../../theme/tokens.dart';
|
||||||
import '../media/signed_media_image.dart';
|
import '../media/signed_media_image.dart';
|
||||||
import 'post_view_mode.dart';
|
import 'post_view_mode.dart';
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ class PostMedia extends StatelessWidget {
|
||||||
double get _imageHeight {
|
double get _imageHeight {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
|
case PostViewMode.sponsored:
|
||||||
return 450.0; // Taller for better resolution/ratio
|
return 450.0; // Taller for better resolution/ratio
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return 600.0;
|
return 600.0;
|
||||||
|
|
@ -114,19 +116,19 @@ class PostMedia extends StatelessWidget {
|
||||||
url: displayUrl,
|
url: displayUrl,
|
||||||
fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover,
|
fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover,
|
||||||
loadingBuilder: (context) => Container(
|
loadingBuilder: (context) => Container(
|
||||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
color: AppTheme.mediaLoadingBg,
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
errorBuilder: (context, error, stackTrace) => Container(
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
color: Colors.red.withValues(alpha: 0.3),
|
color: AppTheme.mediaErrorBg,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
const SizedBox(height: 8),
|
||||||
Text('Error: $error',
|
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(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withOpacity(0.5),
|
color: SojornColors.overlayDark,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: SojornColors.basicWhite, width: 2),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.play_arrow,
|
Icons.play_arrow,
|
||||||
color: Colors.white,
|
color: SojornColors.basicWhite,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../../services/auth_service.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../providers/feed_refresh_provider.dart';
|
import '../../providers/feed_refresh_provider.dart';
|
||||||
|
import '../../theme/tokens.dart';
|
||||||
import '../sojorn_snackbar.dart';
|
import '../sojorn_snackbar.dart';
|
||||||
|
|
||||||
/// Post menu with kebab menu for owner actions (edit/delete).
|
/// Post menu with kebab menu for owner actions (edit/delete).
|
||||||
|
|
@ -133,9 +134,9 @@ class _PostMenuState extends ConsumerState<PostMenu> {
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: SojornColors.destructive),
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
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',
|
value: 'delete',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.delete_outline, size: 20, color: Colors.red),
|
Icon(Icons.delete_outline, size: 20, color: SojornColors.destructive),
|
||||||
SizedBox(width: 8),
|
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,
|
/// Thread view - reduced padding, no card elevation, smaller avatars,
|
||||||
/// connecting lines align correctly, media collapsed
|
/// connecting lines align correctly, media collapsed
|
||||||
thread,
|
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 '../providers/settings_provider.dart';
|
||||||
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../theme/tokens.dart';
|
||||||
import 'post/post_actions.dart';
|
import 'post/post_actions.dart';
|
||||||
import 'post/post_body.dart';
|
import 'post/post_body.dart';
|
||||||
import 'post/post_header.dart';
|
import 'post/post_header.dart';
|
||||||
|
|
@ -82,6 +83,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
EdgeInsets get _padding {
|
EdgeInsets get _padding {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
|
case PostViewMode.sponsored:
|
||||||
return const EdgeInsets.all(AppTheme.spacingMd);
|
return const EdgeInsets.all(AppTheme.spacingMd);
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return const EdgeInsets.all(AppTheme.spacingLg);
|
return const EdgeInsets.all(AppTheme.spacingLg);
|
||||||
|
|
@ -117,6 +119,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
double get _avatarSize {
|
double get _avatarSize {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
|
case PostViewMode.sponsored:
|
||||||
return 36.0;
|
return 36.0;
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return 44.0;
|
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 _isThread => mode == PostViewMode.thread;
|
||||||
bool get _effectiveThreadView => isThreadView || _isThread;
|
bool get _effectiveThreadView => isThreadView || _isThread;
|
||||||
|
|
||||||
|
|
@ -136,7 +141,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
if (_shouldHideNsfw) return const SizedBox.shrink();
|
if (_shouldHideNsfw) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: SojornColors.transparent,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: EdgeInsets.only(bottom: _isThread ? 4 : 16),
|
margin: EdgeInsets.only(bottom: _isThread ? 4 : 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -238,6 +243,32 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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
|
// Body text - clickable for post detail with full background coverage
|
||||||
if (_shouldBlurNsfw) ...[
|
if (_shouldBlurNsfw) ...[
|
||||||
|
|
@ -326,23 +357,23 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
margin: EdgeInsets.symmetric(horizontal: _padding.left, vertical: 8),
|
margin: EdgeInsets.symmetric(horizontal: _padding.left, vertical: 8),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.amber.shade800.withOpacity(0.15),
|
color: AppTheme.nsfwWarningBg,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(SojornRadii.lg),
|
||||||
border: Border.all(color: Colors.amber.shade700.withOpacity(0.3)),
|
border: Border.all(color: AppTheme.nsfwWarningBorder),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.visibility_off, size: 16, color: Colors.amber.shade700),
|
Icon(Icons.visibility_off, size: 16, color: AppTheme.nsfwWarningIcon),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'Sensitive Content',
|
'Sensitive Content',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.amber.shade700,
|
color: AppTheme.nsfwWarningText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -353,7 +384,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
post.nsfwReason!,
|
post.nsfwReason!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.amber.shade600,
|
color: AppTheme.nsfwWarningSubText,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -363,7 +394,7 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
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