feat: implement user reporting in chat and dynamic environment-aware testimonial links
This commit is contained in:
@@ -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(
|
||||||
title: const Text('Report User'),
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
content: TextField(
|
title: const Text('Report User'),
|
||||||
controller: controller,
|
content: Column(
|
||||||
maxLines: 3,
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: const InputDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
hintText: 'Describe the issue...',
|
children: [
|
||||||
border: OutlineInputBorder(),
|
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,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Additional details (optional)...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await _submitReport(selectedReason, descController.text.trim());
|
||||||
|
},
|
||||||
|
child: const Text('Submit'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Report submitted. Thank you.')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
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()) {
|
||||||
|
|||||||
@@ -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,16 +160,25 @@ class _TestimonialsTabState extends ConsumerState<TestimonialsTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
onTap: () async {
|
||||||
child: Text(
|
final uri = Uri.parse(_testimonialLink!);
|
||||||
_testimonialLink!,
|
if (await canLaunchUrl(uri)) {
|
||||||
overflow: TextOverflow.ellipsis,
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
style: const TextStyle(
|
}
|
||||||
fontFamily: 'Fractul',
|
},
|
||||||
fontSize: 14,
|
child: Padding(
|
||||||
fontWeight: FontWeight.w300,
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
color: Colors.black,
|
child: Text(
|
||||||
|
_testimonialLink!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user