Fix reaction UI updates - prioritize local state over post model

Change _reactionCountsFor and _myReactionsFor to prefer local state for immediate UI updates after toggle reactions, falling back to post model data when no local state exists.
This commit is contained in:
Patrick Britton 2026-02-01 13:08:08 -06:00
parent a3c609c0cc
commit 558272c9a2
10 changed files with 65 additions and 81 deletions

View file

@ -168,7 +168,7 @@ class AppRoutes {
/// Navigate to a user profile by username
static void navigateToProfile(BuildContext context, String username) {
context.go('/u/$username');
context.push('/u/$username');
}
/// Get shareable URL for a user profile
@ -203,12 +203,12 @@ class AppRoutes {
static void navigateToBeacon(BuildContext context, LatLng location) {
final url =
'/beacon?lat=${location.latitude.toStringAsFixed(6)}&long=${location.longitude.toStringAsFixed(6)}';
context.go(url);
context.push(url);
}
/// Navigate to secure chat
static void navigateToSecureChat(BuildContext context) {
context.go(secureChat);
context.push(secureChat);
}
}

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/app_routes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart';
@ -106,6 +108,11 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
});
_seedReactionState(focusContext);
// Trigger a rebuild to show the seeded reactions
if (mounted) {
setState(() {});
}
_slideController.forward(from: 0);
_fadeController.forward(from: 0);
}
@ -121,31 +128,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
Future<void> _navigateToPost(String postId) async {
if (_isTransitioning || _focusContext?.targetPost.id == postId) return;
setState(() {
_isTransitioning = true;
_error = null;
});
try {
final api = ref.read(apiServiceProvider);
final focusContext = await api.getPostFocusContext(postId);
if (!mounted) return;
setState(() {
_focusContext = focusContext;
});
_seedReactionState(focusContext);
_slideController.forward(from: 0);
_fadeController.forward(from: 0);
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
});
} finally {
if (mounted) {
setState(() => _isTransitioning = false);
}
}
// Instead of just flipping state, we push a new route to maintain history
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ThreadedConversationScreen(rootPostId: postId),
),
);
}
@override
@ -163,7 +152,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(),
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
// Fallback for direct links
context.go(AppRoutes.homeAlias);
}
},
icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
),
title: AnimatedSwitcher(
@ -809,14 +805,20 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
}
void _seedReactionsForPost(Post post) {
print('DEBUG: _seedReactionsForPost for post ${post.id}');
print('DEBUG: post.reactions = ${post.reactions}');
print('DEBUG: post.myReactions = ${post.myReactions}');
if (post.reactions != null) {
_reactionCountsByPost.putIfAbsent(
post.id,
() => Map<String, int>.from(post.reactions!),
);
print('DEBUG: Seeded reaction counts: ${_reactionCountsByPost[post.id]}');
}
if (post.myReactions != null) {
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
print('DEBUG: Seeded my reactions: ${_myReactionsByPost[post.id]}');
}
if (post.reactionUsers != null) {
_reactionUsersByPost.putIfAbsent(
@ -827,11 +829,30 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
}
Map<String, int> _reactionCountsFor(Post post) {
return _reactionCountsByPost[post.id] ?? post.reactions ?? {};
// Debug: Check what we're getting from the post model
print('DEBUG: _reactionCountsFor for post ${post.id}');
print('DEBUG: post.reactions = ${post.reactions}');
print('DEBUG: _reactionCountsByPost[${post.id}] = ${_reactionCountsByPost[post.id]}');
// Prefer local state for immediate updates after toggle reactions
final localState = _reactionCountsByPost[post.id];
if (localState != null) {
print('DEBUG: Using local state: ${localState}');
return localState;
}
// Fall back to post model if no local state
print('DEBUG: Using post.reactions: ${post.reactions}');
return post.reactions ?? {};
}
Set<String> _myReactionsFor(Post post) {
return _myReactionsByPost[post.id] ?? post.myReactions?.toSet() ?? <String>{};
// Prefer local state for immediate updates after toggle reactions
final localState = _myReactionsByPost[post.id];
if (localState != null) {
return localState;
}
// Fall back to post model if no local state
return post.myReactions?.toSet() ?? <String>{};
}
Map<String, List<String>>? _reactionUsersFor(Post post) {

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/app_routes.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/post.dart';
import '../../models/profile.dart';
@ -527,15 +529,11 @@ class _ViewableProfileScreenState extends ConsumerState<ViewableProfileScreen>
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => const ProfileScreen(),
),
);
// Safely return to home/feed instead of pushing a redundant ProfileScreen
context.go(AppRoutes.homeAlias);
}
},
),

View file

@ -16,7 +16,6 @@ import '../models/tone_analysis.dart';
import 'package:http/http.dart' as http;
/// ApiService - Single source of truth for all backend communication.
/// Migration: Supabase direct reads are being replaced byGo API calls.
class ApiService {
final AuthService _authService;
final http.Client _httpClient = http.Client();
@ -28,7 +27,7 @@ class ApiService {
static ApiService get instance =>
_instance ??= ApiService(AuthService.instance);
/// Generic function caller for Edge Functions. Handles response parsing
/// Generic caller for specialized edge endpoints. Handles response parsing
/// and normalization across different response formats.
Future<Map<String, dynamic>> _callFunction(
String functionName, {

View file

@ -57,12 +57,12 @@ class UploadResult {
/// Progress callback for upload operations
typedef UploadProgressCallback = void Function(double progress);
/// Service for uploading images AND videos to Cloudflare R2 via Supabase Edge Functions
/// Service for uploading images AND videos to Cloudflare R2 via Go Backend
class ImageUploadService {
final AuthService _auth = AuthService.instance;
final _storage = const FlutterSecureStorage();
/// Get the current authentication token (Go backend or Supabase fallback)
/// Get the current authentication token
Future<String?> _getAuthToken() async {
return _auth.accessToken;
}
@ -469,7 +469,7 @@ class ImageUploadService {
onProgress?.call(0.3);
print('Uploading image via edge function...');
print('Uploading image via R2 bridge...');
final streamedResponse = await request.send();
final response = await http.Response.fromStream(streamedResponse);

View file

@ -11,7 +11,7 @@ dependencies:
flutter_localizations:
sdk: flutter
# Supabase (removed - migrated to Go backend)
# Backend Services (Fully migrated to Go API)
firebase_core: ^3.4.0
firebase_messaging: ^15.1.0

View file

@ -5,6 +5,4 @@ echo Starting Sojorn on Chrome...
echo.
flutter run -d chrome ^
--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co ^
--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbGF0eXZwbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M ^
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1

View file

@ -26,7 +26,7 @@ Get-Content $EnvPath | ForEach-Object {
$values[$key] = $value
}
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL')
$required = @('API_BASE_URL')
$missing = $required | Where-Object {
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
}
@ -37,24 +37,9 @@ if ($missing.Count -gt 0) {
}
$defineArgs = @(
"--dart-define=SUPABASE_URL=$($values['SUPABASE_URL'])",
"--dart-define=SUPABASE_ANON_KEY=$($values['SUPABASE_ANON_KEY'])",
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
)
$optionalDefines = @(
'SUPABASE_PUBLISHABLE_KEY',
'SUPABASE_SECRET_KEY',
'SUPABASE_JWT_KID',
'SUPABASE_JWKS_URI'
)
foreach ($opt in $optionalDefines) {
if ($values.ContainsKey($opt) -and -not [string]::IsNullOrWhiteSpace($values[$opt])) {
$defineArgs += "--dart-define=$opt=$($values[$opt])"
}
}
Write-Host "Starting Sojorn on Chrome..." -ForegroundColor Green
Write-Host ""

View file

@ -5,6 +5,4 @@ echo Starting Sojorn in development mode...
echo.
flutter run ^
--dart-define=SUPABASE_URL=https://zwkihedetedlatyvplyz.supabase.co ^
--dart-define=SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp3a2loZWRldGVkbGF0eXZwbHl6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njc2Nzk3OTUsImV4cCI6MjA4MzI1NTc5NX0.7YyYOABjm7cpKa1DiefkI9bH8r6SICJ89nDK9sgUa0M ^
--dart-define=API_BASE_URL=https://api.gosojorn.com/api/v1

View file

@ -26,7 +26,7 @@ Get-Content $EnvPath | ForEach-Object {
$values[$key] = $value
}
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL')
$required = @('API_BASE_URL')
$missing = $required | Where-Object {
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
}
@ -37,24 +37,9 @@ if ($missing.Count -gt 0) {
}
$defineArgs = @(
"--dart-define=SUPABASE_URL=$($values['SUPABASE_URL'])",
"--dart-define=SUPABASE_ANON_KEY=$($values['SUPABASE_ANON_KEY'])",
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
)
$optionalDefines = @(
'SUPABASE_PUBLISHABLE_KEY',
'SUPABASE_SECRET_KEY',
'SUPABASE_JWT_KID',
'SUPABASE_JWKS_URI'
)
foreach ($opt in $optionalDefines) {
if ($values.ContainsKey($opt) -and -not [string]::IsNullOrWhiteSpace($values[$opt])) {
$defineArgs += "--dart-define=$opt=$($values[$opt])"
}
}
Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green
Write-Host ""