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

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
@ -106,6 +108,11 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
}); });
_seedReactionState(focusContext); _seedReactionState(focusContext);
// Trigger a rebuild to show the seeded reactions
if (mounted) {
setState(() {});
}
_slideController.forward(from: 0); _slideController.forward(from: 0);
_fadeController.forward(from: 0); _fadeController.forward(from: 0);
} }
@ -121,31 +128,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
Future<void> _navigateToPost(String postId) async { Future<void> _navigateToPost(String postId) async {
if (_isTransitioning || _focusContext?.targetPost.id == postId) return; if (_isTransitioning || _focusContext?.targetPost.id == postId) return;
setState(() {
_isTransitioning = true;
_error = null;
});
try { // Instead of just flipping state, we push a new route to maintain history
final api = ref.read(apiServiceProvider); Navigator.of(context).push(
final focusContext = await api.getPostFocusContext(postId); MaterialPageRoute(
if (!mounted) return; builder: (_) => ThreadedConversationScreen(rootPostId: postId),
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);
}
}
} }
@override @override
@ -163,7 +152,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
elevation: 0, elevation: 0,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( 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), icon: Icon(Icons.arrow_back, color: AppTheme.navyBlue),
), ),
title: AnimatedSwitcher( title: AnimatedSwitcher(
@ -809,14 +805,20 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
} }
void _seedReactionsForPost(Post post) { 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) { if (post.reactions != null) {
_reactionCountsByPost.putIfAbsent( _reactionCountsByPost.putIfAbsent(
post.id, post.id,
() => Map<String, int>.from(post.reactions!), () => Map<String, int>.from(post.reactions!),
); );
print('DEBUG: Seeded reaction counts: ${_reactionCountsByPost[post.id]}');
} }
if (post.myReactions != null) { if (post.myReactions != null) {
_myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet()); _myReactionsByPost.putIfAbsent(post.id, () => post.myReactions!.toSet());
print('DEBUG: Seeded my reactions: ${_myReactionsByPost[post.id]}');
} }
if (post.reactionUsers != null) { if (post.reactionUsers != null) {
_reactionUsersByPost.putIfAbsent( _reactionUsersByPost.putIfAbsent(
@ -827,11 +829,30 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
} }
Map<String, int> _reactionCountsFor(Post post) { 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) { 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) { Map<String, List<String>>? _reactionUsersFor(Post post) {

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,4 @@ echo Starting Sojorn on Chrome...
echo. echo.
flutter run -d chrome ^ 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 --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 $values[$key] = $value
} }
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL') $required = @('API_BASE_URL')
$missing = $required | Where-Object { $missing = $required | Where-Object {
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_]) -not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
} }
@ -37,24 +37,9 @@ if ($missing.Count -gt 0) {
} }
$defineArgs = @( $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'])" "--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 "Starting Sojorn on Chrome..." -ForegroundColor Green
Write-Host "" Write-Host ""

View file

@ -5,6 +5,4 @@ echo Starting Sojorn in development mode...
echo. echo.
flutter run ^ 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 --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 $values[$key] = $value
} }
$required = @('SUPABASE_URL', 'SUPABASE_ANON_KEY', 'API_BASE_URL') $required = @('API_BASE_URL')
$missing = $required | Where-Object { $missing = $required | Where-Object {
-not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_]) -not $values.ContainsKey($_) -or [string]::IsNullOrWhiteSpace($values[$_])
} }
@ -37,24 +37,9 @@ if ($missing.Count -gt 0) {
} }
$defineArgs = @( $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'])" "--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 "Starting Sojorn in development mode..." -ForegroundColor Green
Write-Host "" Write-Host ""