sojorn/sojorn_app/lib/services/ad_integration_service.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

172 lines
4.9 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/sponsored_post.dart';
import '../models/post.dart';
import '../providers/api_provider.dart';
/// Helper class for integrating sponsored content into feeds
///
/// Usage:
/// 1. Add a SponsoredPost? _currentAd field to your screen
/// 2. Call loadSponsoredPost(categoryId) when loading posts
/// 3. Insert the SponsoredPostCard widget at the desired position in the list
/// 4. Call recordAdImpression() when the ad becomes visible
class AdIntegrationService {
final Function _read;
AdIntegrationService(this._read);
/// Currently loaded sponsored post (null if none available)
SponsoredPost? get currentAd => _currentAd;
SponsoredPost? _currentAd;
/// Load a sponsored post for the given category
Future<SponsoredPost?> loadSponsoredPost(String? categoryId) async {
if (categoryId == null || categoryId.isEmpty) {
return _currentAd;
}
try {
final apiService = _read(apiServiceProvider);
final ad = await apiService.getSponsoredPost(categoryId: categoryId);
if (ad != null) {
_currentAd = ad;
return ad;
}
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
debugPrint('AdIntegrationService: using cached ad for $categoryId');
return _currentAd;
}
debugPrint(
'AdIntegrationService: no sponsored post available for $categoryId');
return null;
} catch (e) {
if (_currentAd != null && _currentAd!.matchesCategory(categoryId)) {
debugPrint('AdIntegrationService: fetch failed, using cached ad: $e');
return _currentAd;
}
debugPrint('AdIntegrationService: fetch failed, no fallback ad: $e');
return null;
}
}
/// Load a sponsored post based on an existing post's category
Future<SponsoredPost?> loadSponsoredPostForPost(Post post) async {
return loadSponsoredPost(post.categoryId);
}
/// Record an impression for the current ad
Future<void> recordAdImpression() async {
if (_currentAd != null) {
try {
final apiService = _read(apiServiceProvider);
await apiService.recordAdImpression(_currentAd!.id);
} catch (e) {
// Silently fail - impression tracking is not critical
}
}
}
/// Clear the current ad (e.g., on refresh)
void clearAd() {
_currentAd = null;
}
/// Check if an ad is currently loaded
bool get hasAd => _currentAd != null;
/// Get the current ad for display
SponsoredPost? getAd() => _currentAd;
}
/// Extension on List to interleave sponsored posts
extension ListAdExtension on List<Post> {
/// Insert sponsored content at regular intervals
///
/// [ad] - The sponsored post to insert
/// [interval] - Insert after every N posts (default: 10)
/// [maxAds] - Maximum number of ads to insert (default: 1)
List<Post> interleaveWithAd(
SponsoredPost? ad, {
int interval = 10,
int maxAds = 1,
SponsoredPost? fallbackAd,
}) {
if (isEmpty) {
return [...this];
}
final activeAd = ad ?? fallbackAd;
if (activeAd == null) {
debugPrint('AdIntegrationService: no ad available to interleave');
return [...this];
}
final result = <Object>[];
int adCount = 0;
final safeInterval = interval <= 0 ? length : interval;
final maxPossibleAds = length ~/ safeInterval;
final effectiveMaxAds = maxAds.clamp(0, maxPossibleAds);
final adPost = _sponsoredPostToPost(activeAd);
for (int i = 0; i < length; i++) {
result.add(this[i]);
// Insert ad after every N posts, up to maxAds
if ((i + 1) % safeInterval == 0 && adCount < effectiveMaxAds) {
result.add(adPost);
adCount++;
}
}
return result.cast<Post>();
}
}
/// Extension on AsyncData to handle ad loading
extension AdLoadingExtension on AsyncValue<List<Post>> {
/// Transform posts to include sponsored content
Future<AsyncValue<List<Post>>> withSponsoredContent(
String? categoryId,
Ref ref,
) async {
final data = asData;
if (data == null) {
return this;
}
final posts = data.value;
final adService = AdIntegrationService(ref.read);
await adService.loadSponsoredPost(categoryId);
final ad = adService.getAd();
final postsWithAds = posts.interleaveWithAd(
ad,
interval: 10,
maxAds: 2,
fallbackAd: adService.getAd(),
);
return AsyncData(postsWithAds);
}
}
Post _sponsoredPostToPost(SponsoredPost ad) {
return Post(
id: ad.id,
authorId: 'sponsored',
categoryId:
ad.targetCategories.isNotEmpty ? ad.targetCategories.first : null,
body: ad.body,
status: PostStatus.active,
detectedTone: ToneLabel.neutral,
contentIntegrityScore: 1.0,
createdAt: DateTime.now(),
allowChain: false,
imageUrl: ad.imageUrl,
isSponsored: true,
advertiserName: ad.advertiserName,
ctaLink: ad.ctaLink,
ctaText: ad.ctaText,
);
}