diff --git a/nginx/sojorn_final.conf b/nginx/sojorn_final.conf index cb429af..e2a1bfe 100644 --- a/nginx/sojorn_final.conf +++ b/nginx/sojorn_final.conf @@ -1,6 +1,8 @@ server { server_name api.sojorn.net; + client_max_body_size 100M; + location / { proxy_pass http://localhost:8080; proxy_set_header Host $host; @@ -11,6 +13,11 @@ server { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + + # Increase timeouts for large file uploads + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; } listen 443 ssl; # managed by Certbot diff --git a/sojorn_app/lib/providers/quip_upload_provider.dart b/sojorn_app/lib/providers/quip_upload_provider.dart index 70f3006..3b243e6 100644 --- a/sojorn_app/lib/providers/quip_upload_provider.dart +++ b/sojorn_app/lib/providers/quip_upload_provider.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import '../services/auth_service.dart'; import '../services/api_service.dart'; import '../services/image_upload_service.dart'; +import 'feed_refresh_provider.dart'; // Define the state class class QuipUploadState { @@ -36,14 +37,13 @@ class QuipUploadState { } } -// Create a Notifier class for Riverpod 3.2.0+ class QuipUploadNotifier extends Notifier { @override QuipUploadState build() { return QuipUploadState(isUploading: false, progress: 0.0); } - Future startUpload(File videoFile, String caption) async { + Future startUpload(File videoFile, String caption, {double? thumbnailTimestampMs}) async { try { state = state.copyWith( isUploading: true, progress: 0.0, error: null, successMessage: null); @@ -60,8 +60,12 @@ class QuipUploadNotifier extends Notifier { final tempDir = await getTemporaryDirectory(); final thumbnailPath = '${tempDir.path}/${timestamp}_thumb.jpg'; + final ss = thumbnailTimestampMs != null + ? (thumbnailTimestampMs / 1000.0).toStringAsFixed(3) + : '00:00:01'; + final session = await FFmpegKit.execute( - '-y -ss 00:00:01 -i "${videoFile.path}" -vframes 1 -q:v 5 "$thumbnailPath"' + '-y -ss $ss -i "${videoFile.path}" -vframes 1 -q:v 2 "$thumbnailPath"' ); final returnCode = await session.getReturnCode(); @@ -102,7 +106,6 @@ class QuipUploadNotifier extends Notifier { state = state.copyWith(progress: 0.8); // Publish post via Go API - // Video goes to video_url, thumbnail to thumbnail_url await ApiService.instance.publishPost( body: caption, videoUrl: videoUrl, @@ -110,10 +113,20 @@ class QuipUploadNotifier extends Notifier { categoryId: null, // Default ); + // Trigger feed refresh + ref.read(feedRefreshProvider.notifier).state++; + state = state.copyWith( isUploading: false, progress: 1.0, successMessage: 'Upload successful'); + + // Auto-reset after 3 seconds so UI goes back to + button + Future.delayed(const Duration(seconds: 3), () { + if (state.progress == 1.0 && !state.isUploading) { + state = QuipUploadState(isUploading: false, progress: 0.0); + } + }); } catch (e) { state = state.copyWith(isUploading: false, error: e.toString()); } diff --git a/sojorn_app/lib/screens/home/home_shell.dart b/sojorn_app/lib/screens/home/home_shell.dart index f52a778..f37b3e8 100644 --- a/sojorn_app/lib/screens/home/home_shell.dart +++ b/sojorn_app/lib/screens/home/home_shell.dart @@ -112,49 +112,69 @@ class _HomeShellState extends State with WidgetsBindingObserver { child: Stack( alignment: Alignment.center, children: [ - // Outer Ring for Upload Progress Consumer( builder: (context, ref, child) { final upload = ref.watch(quipUploadProvider); - - if (!upload.isUploading && upload.progress == 0) { - return const SizedBox.shrink(); - } - - return SizedBox( - width: 64, - height: 64, - child: CircularProgressIndicator( - value: upload.progress, - strokeWidth: 4, - backgroundColor: AppTheme.egyptianBlue.withOpacity(0.1), - valueColor: AlwaysStoppedAnimation( - upload.progress >= 0.99 ? Colors.green : AppTheme.brightNavy - ), + final isDone = !upload.isUploading && upload.progress >= 1.0; + final isUploading = upload.isUploading; + final hasState = isUploading || isDone; + + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: hasState ? AppTheme.brightNavy : AppTheme.navyBlue, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: (hasState ? AppTheme.brightNavy : AppTheme.navyBlue).withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Inside Border Progress + if (hasState) + SizedBox( + width: 50, + height: 50, + child: CustomPaint( + painter: _VerticalBorderProgressPainter( + progress: upload.progress, + color: Colors.white, + backgroundColor: Colors.white.withOpacity(0.2), + strokeWidth: 3.5, + borderRadius: 12, + ), + ), + ), + + // Content: Icon(+) or Percent or Check + if (isDone) + const Icon(Icons.check, color: Colors.white, size: 28) + else if (isUploading) + Text( + '${(upload.progress * 100).toInt()}%', + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ) + else + const Icon( + Icons.add, + color: Colors.white, + size: 32, + ), + ], ), ); }, ), - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppTheme.navyBlue, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppTheme.navyBlue.withOpacity(0.4), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: const Icon( - Icons.add, - color: Colors.white, - size: 32, - ), - ), ], ), ), @@ -331,6 +351,69 @@ class _HomeShellState extends State with WidgetsBindingObserver { } } +class _VerticalBorderProgressPainter extends CustomPainter { + final double progress; + final Color color; + final Color backgroundColor; + final double strokeWidth; + final double borderRadius; + + _VerticalBorderProgressPainter({ + required this.progress, + required this.color, + required this.backgroundColor, + this.strokeWidth = 3.0, + this.borderRadius = 16.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final bgPaint = Paint() + ..color = backgroundColor + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final rect = Rect.fromLTWH( + strokeWidth / 2, + strokeWidth / 2, + size.width - strokeWidth, + size.height - strokeWidth, + ); + final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius)); + + // Draw background border + canvas.drawRRect(rrect, bgPaint); + + // Draw progress border + if (progress > 0) { + final progressPaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + // Clip to vertical progress + canvas.save(); + final clipRect = Rect.fromLTWH( + 0, + size.height * (1.0 - progress), + size.width, + size.height * progress, + ); + canvas.clipRect(clipRect); + canvas.drawRRect(rrect, progressPaint); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant _VerticalBorderProgressPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.color != color || + oldDelegate.backgroundColor != backgroundColor; + } +} + /// Provides the current navigation shell index to descendants that need to /// react (e.g. pausing quip playback when the tab is not active). class NavigationShellScope extends InheritedWidget { diff --git a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart index d800048..720299d 100644 --- a/sojorn_app/lib/screens/post/threaded_conversation_screen.dart +++ b/sojorn_app/lib/screens/post/threaded_conversation_screen.dart @@ -574,7 +574,7 @@ class _ThreadedConversationScreenState extends ConsumerState { late VideoPlayerController _controller; final TextEditingController _captionController = TextEditingController(); double _coverTimestamp = 0.0; - bool _isUploading = false; - final ImageUploadService _uploadService = ImageUploadService(); + // bool _isUploading = false; + // final ImageUploadService _uploadService = ImageUploadService(); @override void initState() { @@ -41,77 +41,25 @@ class _QuipMetadataScreenState extends ConsumerState { } Future _postQuip() async { - if (_isUploading) return; - setState(() => _isUploading = true); + final uploadNotifier = ref.read(quipUploadProvider.notifier); - try { - // 1. Generate Thumbnail - String? thumbnailUrl; - try { - final tempDir = await getTemporaryDirectory(); - final thumbPath = '${tempDir.path}/thumbnail_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final seconds = _coverTimestamp / 1000.0; - - // Execute FFmpeg to extract frame - final session = await FFmpegKit.executeWithArguments([ - '-y', - '-user_agent', 'SojornApp/1.0', - '-ss', seconds.toStringAsFixed(3), - '-i', widget.videoFile.path, - '-vframes', '1', - '-q:v', '5', - thumbPath - ]); - final returnCode = await session.getReturnCode(); - - if (ReturnCode.isSuccess(returnCode)) { - final thumbFile = File(thumbPath); - if (await thumbFile.exists()) { - thumbnailUrl = await _uploadService.uploadImage(thumbFile); - } - } else { - final logs = await session.getLogs(); - final logContent = logs.map((l) => l.getMessage()).join('\n'); - print('FFmpeg thumbnail failed. ReturnCode: $returnCode'); - print('Logs:\n$logContent'); - } - } catch (e) { - print('Thumbnail generation error: $e'); - // Continue without thumbnail - } + // We already have the logic to generate a specific thumbnail in the provider, + // but the screen allows choosing a timestamp. + // To support the chosen cover, we'll generate it here and then pass it + // or just pass the timestamp to the provider. + // Let's pass the chosen timestamp to startUpload. + + uploadNotifier.startUpload( + widget.videoFile, + _captionController.text.trim(), + thumbnailTimestampMs: _coverTimestamp, + ); - // 2. Upload Video - final videoUrl = await _uploadService.uploadVideo( - widget.videoFile, - onProgress: (progress) { - // Optional: Update progress UI - }, + if (mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Upload started in background")), ); - - // 3. Create the Post in the database - final apiService = ref.read(apiServiceProvider); - await apiService.publishPost( - body: _captionController.text.trim(), - videoUrl: videoUrl, - thumbnailUrl: thumbnailUrl, - ); - - if (mounted) { - // Refresh feed to show new quip - ref.read(feedRefreshProvider.notifier).state++; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Quip posted successfully!")), - ); - Navigator.of(context).popUntil((route) => route.isFirst); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Upload failed: $e")), - ); - setState(() => _isUploading = false); - } } } @@ -135,14 +83,12 @@ class _QuipMetadataScreenState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: ElevatedButton( - onPressed: _isUploading ? null : _postQuip, + onPressed: _postQuip, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.brightNavy, shape: const StadiumBorder(), ), - child: _isUploading - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Text("Post"), + child: const Text("Post"), ), ) ], diff --git a/sojorn_app/lib/services/notification_service.dart b/sojorn_app/lib/services/notification_service.dart index 05b380a..365a3f8 100644 --- a/sojorn_app/lib/services/notification_service.dart +++ b/sojorn_app/lib/services/notification_service.dart @@ -486,14 +486,16 @@ class NotificationService { break; case 'like': + case 'quip_reaction': case 'save': case 'comment': case 'reply': case 'mention': - final postId = data['post_id']; + case 'share': + final postId = data['post_id'] ?? data['beacon_id']; final target = data['target']; if (postId != null) { - _navigateToPost(navigator, postId, target); + _navigateToPost(navigator, postId.toString(), target?.toString()); } break; @@ -503,7 +505,7 @@ class NotificationService { case 'follow_accepted': final followerId = data['follower_id']; if (followerId != null) { - navigator.context.push('/u/$followerId'); + navigator.context.push('${AppRoutes.userPrefix}/$followerId'); } else { navigator.context.go(AppRoutes.profile); } @@ -511,7 +513,12 @@ class NotificationService { case 'beacon_vouch': case 'beacon_report': - navigator.context.go(AppRoutes.beaconPrefix); + final beaconId = data['beacon_id'] ?? data['post_id']; + if (beaconId != null) { + _navigateToPost(navigator, beaconId.toString(), 'beacon_map'); + } else { + navigator.context.go(AppRoutes.beaconPrefix); + } break; default: diff --git a/sojorn_app/lib/widgets/post/post_media.dart b/sojorn_app/lib/widgets/post/post_media.dart index 3d7e04b..03833d4 100644 --- a/sojorn_app/lib/widgets/post/post_media.dart +++ b/sojorn_app/lib/widgets/post/post_media.dart @@ -44,31 +44,28 @@ class PostMedia extends StatelessWidget { children: [ ConstrainedBox( constraints: BoxConstraints(maxHeight: _imageHeight), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: SizedBox( - width: double.infinity, - child: SignedMediaImage( - url: post!.imageUrl!, - fit: BoxFit.contain, - loadingBuilder: (context) => Container( - color: AppTheme.queenPink.withValues(alpha: 0.3), - child: const Center(child: CircularProgressIndicator()), - ), - errorBuilder: (context, error, stackTrace) => Container( - color: Colors.red.withValues(alpha: 0.3), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.broken_image, - size: 48, color: Colors.white), - const SizedBox(height: 8), - Text('Error: $error', - style: const TextStyle( - color: Colors.white, fontSize: 10)), - ], - ), + child: SizedBox( + width: double.infinity, + child: SignedMediaImage( + url: post!.imageUrl!, + fit: BoxFit.cover, + loadingBuilder: (context) => Container( + color: AppTheme.queenPink.withValues(alpha: 0.3), + child: const Center(child: CircularProgressIndicator()), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.red.withValues(alpha: 0.3), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.broken_image, + size: 48, color: Colors.white), + const SizedBox(height: 8), + Text('Error: $error', + style: const TextStyle( + color: Colors.white, fontSize: 10)), + ], ), ), ), diff --git a/sojorn_app/lib/widgets/sojorn_post_card.dart b/sojorn_app/lib/widgets/sojorn_post_card.dart index 52eed97..ad962dd 100644 --- a/sojorn_app/lib/widgets/sojorn_post_card.dart +++ b/sojorn_app/lib/widgets/sojorn_post_card.dart @@ -87,7 +87,6 @@ class sojornPostCard extends StatelessWidget { color: Colors.transparent, child: Container( margin: const EdgeInsets.only(bottom: 16), // Add spacing between cards - padding: _padding, decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(20), @@ -103,109 +102,127 @@ class sojornPostCard extends StatelessWidget { ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Chain Context (The Quote Box) - only show in thread view - if (isThreadView && showChainContext && post.chainParent != null) ...[ - ChainQuoteWidget( - parent: post.chainParent!, - onTap: onChainParentTap, - ), - const SizedBox(height: AppTheme.spacingSm), - ], - - // Main Post Content - const SizedBox(height: 4), - // Header row with menu - only header is clickable for profile - Row( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + padding: _padding.copyWith(left: 0, right: 0), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: InkWell( - onTap: () { - final handle = post.author?.handle ?? 'unknown'; - if (handle != 'unknown' && handle.trim().isNotEmpty) { - AppRoutes.navigateToProfile(context, handle); - } - }, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 4, + // Internal horizontal padding for text/actions + Padding( + padding: EdgeInsets.symmetric(horizontal: _padding.left), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Chain Context (The Quote Box) - only show in thread view + if (isThreadView && showChainContext && post.chainParent != null) ...[ + ChainQuoteWidget( + parent: post.chainParent!, + onTap: onChainParentTap, + ), + const SizedBox(height: AppTheme.spacingSm), + ], + + // Main Post Content + const SizedBox(height: 4), + // Header row with menu - only header is clickable for profile + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: InkWell( + onTap: () { + final handle = post.author?.handle ?? 'unknown'; + if (handle != 'unknown' && handle.trim().isNotEmpty) { + AppRoutes.navigateToProfile(context, handle); + } + }, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: PostHeader( + post: post, + avatarSize: _avatarSize, + mode: mode, + ), + ), + ), + ), + GestureDetector( + onTap: () => SanctuarySheet.show(context, post), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.ksuPurple.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text("!", style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: AppTheme.royalPurple.withOpacity(0.7), + )), + ), + ), + PostMenu( + post: post, + onPostDeleted: onPostChanged, + ), + ], ), - child: PostHeader( - post: post, - avatarSize: _avatarSize, - mode: mode, + const SizedBox(height: 16), + + // Body text - clickable for post detail with full background coverage + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: PostBody( + text: post.body, + bodyFormat: post.bodyFormat, + backgroundId: post.backgroundId, + mode: mode, + ), + ), ), - ), + ], ), ), - GestureDetector( - onTap: () => SanctuarySheet.show(context, post), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.ksuPurple.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), + + // Media (if available) - clickable for post detail + if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[ + const SizedBox(height: 12), + InkWell( + onTap: onTap, + child: PostMedia( + post: post, + mode: mode, ), - child: Text("!", style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: AppTheme.royalPurple.withOpacity(0.7), - )), + ), + ], + + // Actions section - with padding + const SizedBox(height: 16), + Padding( + padding: EdgeInsets.symmetric(horizontal: _padding.left), + child: PostActions( + post: post, + onChain: onChain, + onPostChanged: onPostChanged, + isThreadView: isThreadView, + showReactions: isThreadView, ), ), - PostMenu( - post: post, - onPostDeleted: onPostChanged, - ), + const SizedBox(height: 4), ], ), - const SizedBox(height: 16), - - // Body text - clickable for post detail with full background coverage - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 4), - child: PostBody( - text: post.body, - bodyFormat: post.bodyFormat, - backgroundId: post.backgroundId, - mode: mode, - ), - ), - ), - - // Media (if available) - clickable for post detail - if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[ - const SizedBox(height: 16), - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppTheme.radiusMd), - child: PostMedia( - post: post, - mode: mode, - ), - ), - ], - const SizedBox(height: 20), - - // Actions - PostActions( - post: post, - onChain: onChain, - onPostChanged: onPostChanged, - isThreadView: isThreadView, - showReactions: isThreadView, - ), - ], + ), ), ), );