feat: implement user reporting in chat and dynamic environment-aware testimonial links

This commit is contained in:
pradeepkumar
2026-04-09 17:02:20 +05:30
parent 44278b7eb0
commit 63288fa06a
2 changed files with 132 additions and 35 deletions

View File

@@ -4,7 +4,10 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:dio/dio.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/network/api_client.dart';
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart'; import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
@@ -349,38 +352,102 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
} }
void _showReportDialog() { void _showReportDialog() {
final controller = TextEditingController(); final descController = TextEditingController();
String selectedReason = 'Spam';
final reasons = [
'Spam',
'Harassment',
'Inappropriate Content',
'Scam / Fraud',
'Other',
];
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Report User'), title: const Text('Report User'),
content: TextField( content: Column(
controller: controller, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Reason:', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: selectedReason,
items: reasons.map((r) => DropdownMenuItem(value: r, child: Text(r))).toList(),
onChanged: (v) {
if (v != null) setDialogState(() => selectedReason = v);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: descController,
maxLines: 3, maxLines: 3,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Describe the issue...', hintText: 'Additional details (optional)...',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
],
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar( await _submitReport(selectedReason, descController.text.trim());
const SnackBar(content: Text('Report submitted. Thank you.')),
);
}, },
child: const Text('Submit'), child: const Text('Submit'),
), ),
], ],
), ),
),
); );
} }
Future<void> _submitReport(String reason, String description) async {
// Find the other party's userId for the report
final conversations = ref.read(messagingProvider).conversations;
final conv = conversations.where((c) => c.id == widget.conversationId).firstOrNull;
if (conv == null) return;
// otherParty.userId is the actual user ID (for agents),
// for regular users otherParty.id is the agentProfileId — we need the userId behind it
final reportedUserId = conv.otherParty.userId ?? conv.otherParty.id;
try {
final dio = ApiClient.instance.dio;
await dio.post('/user-reports', data: {
'reportedUserId': reportedUserId,
'conversationId': widget.conversationId,
'reason': reason,
if (description.isNotEmpty) 'description': description,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Report submitted. Thank you.')),
);
}
} catch (e) {
if (mounted) {
final msg = (e is DioException && e.error is ApiException)
? (e.error as ApiException).message
: 'Failed to submit report';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg)),
);
}
}
}
void _handleBack() { void _handleBack() {
// deactivate() will clear activeConversationId — no need to do it here. // deactivate() will clear activeConversationId — no need to do it here.
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {

View File

@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart'; import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
import 'package:url_launcher/url_launcher.dart';
class TestimonialsTab extends ConsumerStatefulWidget { class TestimonialsTab extends ConsumerStatefulWidget {
const TestimonialsTab({super.key}); const TestimonialsTab({super.key});
@@ -26,6 +28,22 @@ class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
_loadTestimonials(); _loadTestimonials();
} }
/// Derive the web app URL from the current API base URL.
/// api: https://beta.re-quest.com/api/v1 → web: https://beta.re-quest.com
/// api: https://prod.api.re-quest.com/api/v1 → web: https://prod.re-quest.com
String _getWebBaseUrl() {
final apiUrl = AppConfig.apiBaseUrl; // e.g. https://beta.re-quest.com/api/v1
try {
final uri = Uri.parse(apiUrl);
final host = uri.host; // e.g. beta.re-quest.com or prod.api.re-quest.com
// Remove "api." prefix if present (prod uses subdomain-based API)
final webHost = host.replaceFirst('api.', '');
return '${uri.scheme}://$webHost';
} catch (_) {
return 'https://re-quest.com';
}
}
Future<void> _loadTestimonials() async { Future<void> _loadTestimonials() async {
if (_isLoadingTestimonials) return; if (_isLoadingTestimonials) return;
setState(() => _isLoadingTestimonials = true); setState(() => _isLoadingTestimonials = true);
@@ -46,8 +64,11 @@ class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
try { try {
final repo = ref.read(profileRepositoryProvider); final repo = ref.read(profileRepositoryProvider);
final token = await repo.generateTestimonialLink(); final token = await repo.generateTestimonialLink();
// Derive the web app URL from the API base URL so the link works
// correctly across all environments (dev, beta, prod).
final webBaseUrl = _getWebBaseUrl();
setState(() { setState(() {
_testimonialLink = 'https://re-quest.co/testimonials/$token'; _testimonialLink = '$webBaseUrl/testimonials/$token';
_isGeneratingLink = false; _isGeneratingLink = false;
}); });
} catch (e) { } catch (e) {
@@ -139,6 +160,13 @@ class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: GestureDetector(
onTap: () async {
final uri = Uri.parse(_testimonialLink!);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
},
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text( child: Text(
@@ -148,7 +176,9 @@ class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
fontFamily: 'Fractul', fontFamily: 'Fractul',
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
color: Colors.black, color: AppColors.accentOrange,
decoration: TextDecoration.underline,
),
), ),
), ),
), ),