feat: integrate CommentsProvider and fix navigation issues

Main.dart changes:
- Add CommentsProvider to app-level provider tree
- Use ChangeNotifierProxyProvider2 for proper dependency injection
- Integrate with AuthProvider and VoteProvider
- Add NotFoundError screen for missing post routes
- Ensure proper provider lifecycle management

PostCard changes:
- Add disableNavigation parameter to prevent recursive navigation
- Fix issue where tapping post on detail screen creates infinite stack
- Maintain backward compatibility (defaults to clickable)

Architecture improvements:
- Follows existing FeedProvider pattern for consistency
- Proper provider disposal handled by framework
- Hot reload support via provider reuse pattern
- No manual provider creation in widgets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+203 -44
lib
+49
lib/main.dart
···
import 'package:provider/provider.dart';
import 'config/oauth_config.dart';
import 'providers/auth_provider.dart';
import 'providers/feed_provider.dart';
import 'providers/vote_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/home/main_shell_screen.dart';
import 'screens/landing_screen.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
···
);
},
),
// StreamableService for video embeds
Provider<StreamableService>(create: (_) => StreamableService()),
],
···
useMaterial3: true,
),
routerConfig: _createRouter(authProvider),
debugShowCheckedModeBanner: false,
);
}
···
GoRoute(
path: '/feed',
builder: (context, state) => const MainShellScreen(),
),
],
refreshListenable: authProvider,
···
import 'package:provider/provider.dart';
import 'config/oauth_config.dart';
+
import 'models/post.dart';
import 'providers/auth_provider.dart';
+
import 'providers/comments_provider.dart';
import 'providers/feed_provider.dart';
import 'providers/vote_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/home/main_shell_screen.dart';
+
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
+
import 'widgets/loading_error_states.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
···
);
},
),
+
ChangeNotifierProxyProvider2<AuthProvider, VoteProvider,
+
CommentsProvider>(
+
create:
+
(context) => CommentsProvider(
+
authProvider,
+
voteProvider: context.read<VoteProvider>(),
+
voteService: voteService,
+
),
+
update: (context, auth, vote, previous) {
+
// Reuse existing provider to maintain state across rebuilds
+
return previous ??
+
CommentsProvider(
+
auth,
+
voteProvider: vote,
+
voteService: voteService,
+
);
+
},
+
),
// StreamableService for video embeds
Provider<StreamableService>(create: (_) => StreamableService()),
],
···
useMaterial3: true,
),
routerConfig: _createRouter(authProvider),
+
restorationScopeId: 'app',
debugShowCheckedModeBanner: false,
);
}
···
GoRoute(
path: '/feed',
builder: (context, state) => const MainShellScreen(),
+
),
+
GoRoute(
+
path: '/post/:postUri',
+
builder: (context, state) {
+
// Extract post from state.extra
+
final post = state.extra as FeedViewPost?;
+
+
// If no post provided via extra, show user-friendly error
+
if (post == null) {
+
if (kDebugMode) {
+
print('⚠️ PostDetailScreen: No post provided in route extras');
+
}
+
// Show not found screen with option to go back
+
return NotFoundError(
+
title: 'Post Not Found',
+
message:
+
'This post could not be loaded. It may have been deleted or the link is invalid.',
+
onBackPressed: () {
+
// Navigate back to feed
+
context.go('/feed');
+
},
+
);
+
}
+
+
return PostDetailScreen(post: post);
+
},
),
],
refreshListenable: authProvider,
+154 -44
lib/widgets/post_card.dart
···
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../constants/app_colors.dart';
···
/// - Periodic updates of time strings
/// - Deterministic testing without DateTime.now()
class PostCard extends StatelessWidget {
-
const PostCard({required this.post, this.currentTime, super.key});
final FeedViewPost post;
final DateTime? currentTime;
@override
Widget build(BuildContext context) {
···
),
const SizedBox(height: 8),
-
// Post title
-
if (post.post.title != null) ...[
-
Text(
-
post.post.title!,
-
style: const TextStyle(
-
color: AppColors.textPrimary,
-
fontSize: 16,
-
fontWeight: FontWeight.w400,
),
-
),
-
],
-
// Spacing after title (only if we have content below)
-
if (post.post.title != null &&
-
(post.post.embed?.external != null ||
-
post.post.text.isNotEmpty))
-
const SizedBox(height: 8),
-
// Embed (link preview)
-
if (post.post.embed?.external != null) ...[
-
_EmbedCard(
-
embed: post.post.embed!.external!,
-
streamableService: context.read<StreamableService>(),
-
),
-
const SizedBox(height: 8),
-
],
-
// Post text body preview
-
if (post.post.text.isNotEmpty) ...[
-
Container(
-
padding: const EdgeInsets.all(10),
-
decoration: BoxDecoration(
-
color: AppColors.backgroundSecondary,
-
borderRadius: BorderRadius.circular(8),
-
),
-
child: Text(
-
post.post.text,
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.7),
-
fontSize: 13,
-
height: 1.4,
-
),
-
maxLines: 5,
-
overflow: TextOverflow.ellipsis,
-
),
),
-
],
// External link (if present)
if (post.post.embed?.external != null) ...[
···
const SizedBox(height: 4),
// Action buttons row
-
PostCardActions(post: post),
],
),
),
···
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../constants/app_colors.dart';
···
/// - Periodic updates of time strings
/// - Deterministic testing without DateTime.now()
class PostCard extends StatelessWidget {
+
const PostCard({
+
required this.post,
+
this.currentTime,
+
this.showCommentButton = true,
+
this.disableNavigation = false,
+
super.key,
+
});
final FeedViewPost post;
final DateTime? currentTime;
+
final bool showCommentButton;
+
final bool disableNavigation;
+
+
/// Check if this post should be clickable
+
/// Only text posts (no embeds or non-video/link embeds) are clickable
+
bool get _isClickable {
+
// If navigation is explicitly disabled (e.g., on detail screen), not clickable
+
if (disableNavigation) {
+
return false;
+
}
+
+
final embed = post.post.embed;
+
+
// If no embed, it's a text-only post - clickable
+
if (embed == null) {
+
return true;
+
}
+
+
// If embed exists, check if it's a video or link type
+
final external = embed.external;
+
if (external == null) {
+
return true; // No external embed, clickable
+
}
+
+
final embedType = external.embedType;
+
+
// Video and video-stream posts should NOT be clickable (they have their own tap handling)
+
if (embedType == 'video' || embedType == 'video-stream') {
+
return false;
+
}
+
+
// Link embeds should NOT be clickable (they have their own link handling)
+
if (embedType == 'link') {
+
return false;
+
}
+
+
// All other types are clickable
+
return true;
+
}
+
+
void _navigateToDetail(BuildContext context) {
+
// Navigate to post detail screen
+
// Use URI-encoded version of the post URI for the URL path
+
// Pass the full post object via extras
+
final encodedUri = Uri.encodeComponent(post.post.uri);
+
context.push('/post/$encodedUri', extra: post);
+
}
@override
Widget build(BuildContext context) {
···
),
const SizedBox(height: 8),
+
// Wrap content in InkWell if clickable (text-only posts)
+
if (_isClickable)
+
InkWell(
+
onTap: () => _navigateToDetail(context),
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Post title
+
if (post.post.title != null) ...[
+
Text(
+
post.post.title!,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 16,
+
fontWeight: FontWeight.w400,
+
),
+
),
+
],
+
+
// Spacing after title (only if we have text)
+
if (post.post.title != null && post.post.text.isNotEmpty)
+
const SizedBox(height: 8),
+
+
// Post text body preview
+
if (post.post.text.isNotEmpty) ...[
+
Container(
+
padding: const EdgeInsets.all(10),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
borderRadius: BorderRadius.circular(8),
+
),
+
child: Text(
+
post.post.text,
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.7),
+
fontSize: 13,
+
height: 1.4,
+
),
+
maxLines: 5,
+
overflow: TextOverflow.ellipsis,
+
),
+
),
+
],
+
],
),
+
)
+
else
+
// Non-clickable content (video/link posts)
+
Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Post title
+
if (post.post.title != null) ...[
+
Text(
+
post.post.title!,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 16,
+
fontWeight: FontWeight.w400,
+
),
+
),
+
],
+
// Spacing after title (only if we have content below)
+
if (post.post.title != null &&
+
(post.post.embed?.external != null ||
+
post.post.text.isNotEmpty))
+
const SizedBox(height: 8),
+
// Embed (link preview)
+
if (post.post.embed?.external != null) ...[
+
_EmbedCard(
+
embed: post.post.embed!.external!,
+
streamableService: context.read<StreamableService>(),
+
),
+
const SizedBox(height: 8),
+
],
+
// Post text body preview
+
if (post.post.text.isNotEmpty) ...[
+
Container(
+
padding: const EdgeInsets.all(10),
+
decoration: BoxDecoration(
+
color: AppColors.backgroundSecondary,
+
borderRadius: BorderRadius.circular(8),
+
),
+
child: Text(
+
post.post.text,
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.7),
+
fontSize: 13,
+
height: 1.4,
+
),
+
maxLines: 5,
+
overflow: TextOverflow.ellipsis,
+
),
+
),
+
],
+
],
),
// External link (if present)
if (post.post.embed?.external != null) ...[
···
const SizedBox(height: 4),
// Action buttons row
+
PostCardActions(
+
post: post,
+
showCommentButton: showCommentButton,
+
),
],
),
),