**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.
228 lines
6.9 KiB
Dart
228 lines
6.9 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import '../services/auth_service.dart';
|
|
import '../services/api_service.dart';
|
|
import '../screens/admin/admin_dashboard_screen.dart';
|
|
import '../screens/admin/admin_scaffold.dart';
|
|
import '../screens/admin/admin_user_base_screen.dart';
|
|
import '../screens/admin/moderation_queue_screen.dart';
|
|
import '../screens/beacon/beacon_screen.dart';
|
|
import '../screens/home/feed_personal_screen.dart';
|
|
import '../screens/home/home_shell.dart';
|
|
import '../screens/quips/create/quip_creation_flow.dart';
|
|
import '../screens/quips/feed/quips_feed_screen.dart';
|
|
import '../screens/profile/profile_screen.dart';
|
|
import '../screens/profile/viewable_profile_screen.dart';
|
|
import '../screens/auth/auth_gate.dart';
|
|
import '../screens/secure_chat/secure_chat_list_screen.dart';
|
|
|
|
/// App routing config (GoRouter).
|
|
class AppRoutes {
|
|
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
|
GlobalKey<NavigatorState>();
|
|
|
|
static const String home = '/';
|
|
static const String homeAlias = '/home';
|
|
static const String userPrefix = '/u';
|
|
static const String postPrefix = '/p';
|
|
static const String beaconPrefix = '/beacon';
|
|
static const String quips = '/quips';
|
|
static const String profile = '/profile';
|
|
static const String secureChat = '/secure-chat';
|
|
static const String quipCreate = '/quips/create';
|
|
|
|
static final AuthRefreshNotifier _authRefreshNotifier =
|
|
AuthRefreshNotifier(AuthService.instance.authStateChanges);
|
|
|
|
static final GoRouter router = GoRouter(
|
|
navigatorKey: rootNavigatorKey,
|
|
initialLocation: homeAlias,
|
|
refreshListenable: _authRefreshNotifier,
|
|
redirect: _adminRedirect,
|
|
routes: [
|
|
GoRoute(
|
|
path: home,
|
|
redirect: (_, __) => homeAlias,
|
|
),
|
|
GoRoute(
|
|
path: '$userPrefix/:username',
|
|
parentNavigatorKey: rootNavigatorKey,
|
|
builder: (_, state) => ViewableProfileScreen(
|
|
handle: state.pathParameters['username'] ?? '',
|
|
),
|
|
),
|
|
GoRoute(
|
|
path: quipCreate,
|
|
parentNavigatorKey: rootNavigatorKey,
|
|
builder: (_, __) => const QuipCreationFlow(),
|
|
),
|
|
GoRoute(
|
|
path: secureChat,
|
|
parentNavigatorKey: rootNavigatorKey,
|
|
builder: (_, __) => const SecureChatListScreen(),
|
|
),
|
|
StatefulShellRoute.indexedStack(
|
|
builder: (context, state, navigationShell) => AuthGate(
|
|
authenticatedChild: HomeShell(navigationShell: navigationShell),
|
|
),
|
|
branches: [
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: homeAlias,
|
|
builder: (_, __) => const FeedPersonalScreen(),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: beaconPrefix,
|
|
builder: (_, state) {
|
|
final latParam = state.uri.queryParameters['lat'];
|
|
final longParam = state.uri.queryParameters['long'];
|
|
final lat = latParam != null ? double.tryParse(latParam) : null;
|
|
final long = longParam != null ? double.tryParse(longParam) : null;
|
|
|
|
if (lat != null && long != null) {
|
|
return BeaconScreen(initialMapCenter: LatLng(lat, long));
|
|
}
|
|
|
|
return const BeaconScreen();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: quips,
|
|
builder: (_, __) => const QuipsFeedScreen(),
|
|
),
|
|
],
|
|
),
|
|
StatefulShellBranch(
|
|
routes: [
|
|
GoRoute(
|
|
path: profile,
|
|
builder: (_, __) => const ProfileScreen(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
ShellRoute(
|
|
builder: (context, state, child) {
|
|
final index = _adminIndexForPath(state.uri.path);
|
|
return AdminScaffold(selectedIndex: index, child: child);
|
|
},
|
|
routes: [
|
|
GoRoute(
|
|
path: '/admin',
|
|
builder: (_, __) => const AdminDashboardScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/admin/moderation',
|
|
builder: (_, __) => const ModerationQueueScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/admin/users',
|
|
builder: (_, __) => const AdminUserBaseScreen(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
|
|
static int _adminIndexForPath(String path) {
|
|
if (path.startsWith('/admin/moderation')) return 1;
|
|
if (path.startsWith('/admin/users')) return 2;
|
|
return 0;
|
|
}
|
|
|
|
static FutureOr<String?> _adminRedirect(
|
|
BuildContext context,
|
|
GoRouterState state,
|
|
) async {
|
|
final path = state.uri.path;
|
|
if (!path.startsWith('/admin')) return null;
|
|
|
|
final user = AuthService.instance.currentUser;
|
|
if (user == null) return homeAlias;
|
|
|
|
try {
|
|
final data = await ApiService.instance.callGoApi('/profile', method: 'GET');
|
|
final profile = data['profile'];
|
|
if (profile is Map<String, dynamic>) {
|
|
final role = profile['role'] as String?;
|
|
if (role == 'admin' || role == 'moderator') {
|
|
return null;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
return homeAlias;
|
|
}
|
|
|
|
/// Navigate to a user profile by username
|
|
static void navigateToProfile(BuildContext context, String username) {
|
|
context.go('/u/$username');
|
|
}
|
|
|
|
/// Get shareable URL for a user profile
|
|
/// Returns: https://gosojorn.com/u/username
|
|
static String getProfileUrl(
|
|
String username, {
|
|
String baseUrl = 'https://gosojorn.com',
|
|
}) {
|
|
return '$baseUrl/u/$username';
|
|
}
|
|
|
|
/// Get shareable URL for a post (future implementation)
|
|
/// Returns: https://gosojorn.com/p/postid
|
|
static String getPostUrl(
|
|
String postId, {
|
|
String baseUrl = 'https://gosojorn.com',
|
|
}) {
|
|
return '$baseUrl/p/$postId';
|
|
}
|
|
|
|
/// Get shareable URL for a beacon location
|
|
/// Returns: https://gosojorn.com/beacon?lat=...&long=...
|
|
static String getBeaconUrl(
|
|
double lat,
|
|
double long, {
|
|
String baseUrl = 'https://gosojorn.com',
|
|
}) {
|
|
return '$baseUrl/beacon?lat=${lat.toStringAsFixed(6)}&long=${long.toStringAsFixed(6)}';
|
|
}
|
|
|
|
/// Navigate to a beacon location
|
|
static void navigateToBeacon(BuildContext context, LatLng location) {
|
|
final url =
|
|
'/beacon?lat=${location.latitude.toStringAsFixed(6)}&long=${location.longitude.toStringAsFixed(6)}';
|
|
context.go(url);
|
|
}
|
|
|
|
/// Navigate to secure chat
|
|
static void navigateToSecureChat(BuildContext context) {
|
|
context.go(secureChat);
|
|
}
|
|
}
|
|
|
|
class AuthRefreshNotifier extends ChangeNotifier {
|
|
late final StreamSubscription<AuthState> _subscription;
|
|
|
|
AuthRefreshNotifier(Stream<AuthState> stream) {
|
|
_subscription = stream.listen((_) => notifyListeners());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscription.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|