fix river manual image request

This commit is contained in:
ALim Aidrus 2025-10-08 22:27:42 +08:00
parent 37874a1eab
commit 077efa745d
3 changed files with 444 additions and 41 deletions

View File

@ -1,63 +1,427 @@
// lib/screens/river/manual/river_manual_image_request.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:provider/provider.dart';
import 'package:dropdown_search/dropdown_search.dart';
import 'package:intl/intl.dart';
import 'dart:convert';
import '../../../auth_provider.dart';
import '../../../services/api_service.dart';
class RiverManualImageRequest extends StatelessWidget {
const RiverManualImageRequest({super.key});
class RiverManualImageRequest extends StatefulWidget {
@override
State<RiverManualImageRequest> createState() => _RiverManualImageRequestState();
Widget build(BuildContext context) {
return const RiverImageRequestScreen();
}
}
class _RiverManualImageRequestState extends State<RiverManualImageRequest> {
XFile? _image;
final picker = ImagePicker();
final _descriptionController = TextEditingController();
Future<void> _pickImage() async {
final pickedFile = await picker.pickImage(source: ImageSource.camera);
setState(() => _image = pickedFile);
class RiverImageRequestScreen extends StatefulWidget {
const RiverImageRequestScreen({super.key});
@override
State<RiverImageRequestScreen> createState() => _RiverImageRequestScreenState();
}
class _RiverImageRequestScreenState extends State<RiverImageRequestScreen> {
final _formKey = GlobalKey<FormState>();
final _dateController = TextEditingController();
final String _selectedSamplingType = 'In-Situ Sampling';
String? _selectedStateName;
String? _selectedBasinName;
Map<String, dynamic>? _selectedStation;
DateTime? _selectedDate;
List<String> _statesList = [];
List<String> _basinsForState = [];
List<Map<String, dynamic>> _stationsForBasin = [];
bool _isLoading = false;
List<String> _imageUrls = [];
final Set<String> _selectedImageUrls = {};
@override
void initState() {
super.initState();
_initializeStationFilters();
}
@override
void dispose() {
_dateController.dispose();
super.dispose();
}
void _initializeStationFilters() {
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.riverManualStations ?? [];
if (allStations.isNotEmpty) {
final states = allStations.map((s) => s['state_name'] as String?).whereType<String>().toSet().toList();
states.sort();
setState(() {
_statesList = states;
});
}
}
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
_dateController.text = DateFormat('yyyy-MM-dd').format(_selectedDate!);
});
}
}
Future<void> _searchImages() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
_imageUrls = [];
_selectedImageUrls.clear();
});
if (_selectedStation == null || _selectedDate == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: Station and date are required.'), backgroundColor: Colors.red),
);
setState(() => _isLoading = false);
}
return;
}
final stationId = _selectedStation!['station_id'];
final apiService = Provider.of<ApiService>(context, listen: false);
try {
final result = await apiService.river.getRiverSamplingImages(
stationId: stationId,
samplingDate: _selectedDate!,
samplingType: _selectedSamplingType,
);
if (mounted && result['success'] == true) {
// The backend now returns a direct list of full URLs, so we can use it directly.
final List<String> fetchedUrls = List<String>.from(result['data'] ?? []);
setState(() {
_imageUrls = fetchedUrls;
});
debugPrint("[Image Request] Successfully received and processed ${_imageUrls.length} image URLs.");
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Failed to fetch images.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('An error occurred: $e')));
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
Future<void> _showEmailDialog() async {
final emailController = TextEditingController();
final dialogFormKey = GlobalKey<FormState>();
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setDialogState) {
bool isSending = false;
return AlertDialog(
title: const Text('Send Images via Email'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isSending)
const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator(), SizedBox(width: 24), Text("Sending...")],
),
)
else
Form(
key: dialogFormKey,
child: TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Recipient Email Address', hintText: 'user@example.com'),
validator: (value) {
if (value == null || value.isEmpty || !RegExp(r'\S+@\S+\.\S+').hasMatch(value)) {
return 'Please enter a valid email address.';
}
return null;
},
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: isSending ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: isSending ? null : () async {
if (dialogFormKey.currentState!.validate()) {
setDialogState(() => isSending = true);
await _sendEmailRequestToServer(emailController.text);
if (mounted) Navigator.of(context).pop();
}
},
child: const Text('Send'),
),
],
);
},
);
},
);
}
Future<void> _sendEmailRequestToServer(String toEmail) async {
final apiService = Provider.of<ApiService>(context, listen: false);
try {
final stationCode = _selectedStation?['sampling_station_code'] ?? 'N/A';
final stationName = _selectedStation?['sampling_river'] ?? 'N/A';
final fullStationIdentifier = '$stationCode - $stationName';
final result = await apiService.river.sendImageRequestEmail(
recipientEmail: toEmail,
imageUrls: _selectedImageUrls.toList(),
stationName: fullStationIdentifier,
samplingDate: _dateController.text,
);
if (mounted) {
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Success! Email is being sent by the server.'), backgroundColor: Colors.green),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${result['message']}'), backgroundColor: Colors.red),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('An error occurred: $e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("River Manual Image Request")),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
appBar: AppBar(title: const Text("River Image Request")),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
ElevatedButton.icon(
icon: Icon(Icons.camera_alt),
label: Text("Capture Image"),
onPressed: _pickImage,
),
SizedBox(height: 16),
if (_image != null)
Image.file(
File(_image!.path),
height: 200,
),
SizedBox(height: 16),
TextField(
controller: _descriptionController,
decoration: InputDecoration(labelText: "Description"),
maxLines: 3,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Submit logic here
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Image request submitted")),
);
Text("Image Search Filters", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 24),
// State Dropdown
DropdownSearch<String>(
items: _statesList,
selectedItem: _selectedStateName,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search State..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select State *", border: OutlineInputBorder())),
onChanged: (state) {
setState(() {
_selectedStateName = state;
_selectedBasinName = null;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.riverManualStations ?? [];
final basins = state != null ? allStations.where((s) => s['state_name'] == state).map((s) => s['sampling_basin'] as String?).whereType<String>().toSet().toList() : <String>[];
basins.sort();
_basinsForState = basins;
_stationsForBasin = [];
});
},
child: Text("Submit Request"),
validator: (val) => val == null ? "State is required" : null,
),
const SizedBox(height: 16),
// Basin Dropdown
DropdownSearch<String>(
items: _basinsForState,
selectedItem: _selectedBasinName,
enabled: _selectedStateName != null,
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Basin..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Basin *", border: OutlineInputBorder())),
onChanged: (basin) {
setState(() {
_selectedBasinName = basin;
_selectedStation = null;
final auth = Provider.of<AuthProvider>(context, listen: false);
final allStations = auth.riverManualStations ?? [];
_stationsForBasin = basin != null ? (allStations.where((s) => s['state_name'] == _selectedStateName && s['sampling_basin'] == basin).toList()..sort((a, b) => (a['sampling_station_code'] ?? '').compareTo(b['sampling_station_code'] ?? ''))) : [];
});
},
validator: (val) => _selectedStateName != null && val == null ? "Basin is required" : null,
),
const SizedBox(height: 16),
// Station Dropdown
DropdownSearch<Map<String, dynamic>>(
items: _stationsForBasin,
selectedItem: _selectedStation,
enabled: _selectedBasinName != null,
itemAsString: (station) => "${station['sampling_station_code']} - ${station['sampling_river']}",
popupProps: const PopupProps.menu(showSearchBox: true, searchFieldProps: TextFieldProps(decoration: InputDecoration(hintText: "Search Station..."))),
dropdownDecoratorProps: const DropDownDecoratorProps(dropdownSearchDecoration: InputDecoration(labelText: "Select Station *", border: OutlineInputBorder())),
onChanged: (station) => setState(() => _selectedStation = station),
validator: (val) => _selectedBasinName != null && val == null ? "Station is required" : null,
),
const SizedBox(height: 16),
// Date Picker
TextFormField(
controller: _dateController,
readOnly: true,
decoration: InputDecoration(
labelText: 'Select Date *',
hintText: 'Tap to pick a date',
border: const OutlineInputBorder(),
suffixIcon: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _selectDate),
),
onTap: _selectDate,
validator: (val) => val == null || val.isEmpty ? "Date is required" : null,
),
const SizedBox(height: 32),
// Search Button
ElevatedButton.icon(
icon: const Icon(Icons.search),
label: const Text('Search Images'),
onPressed: _isLoading ? null : _searchImages,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 24),
const Divider(thickness: 1),
const SizedBox(height: 16),
Text("Results", style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
_buildResults(),
// Send Email Button
if (_selectedImageUrls.isNotEmpty) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.email_outlined),
label: Text('Send (${_selectedImageUrls.length}) Selected Image(s)'),
onPressed: _showEmailDialog,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
],
),
),
);
}
Widget _buildResults() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_imageUrls.isEmpty) {
return const Center(
child: Text('No images found. Please adjust your filters and search again.', textAlign: TextAlign.center),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
itemCount: _imageUrls.length,
itemBuilder: (context, index) {
final imageUrl = _imageUrls[index];
final isSelected = _selectedImageUrls.contains(imageUrl);
return GestureDetector(
onTap: () {
setState(() {
if (isSelected) {
_selectedImageUrls.remove(imageUrl);
} else {
_selectedImageUrls.add(imageUrl);
}
});
},
child: Card(
clipBehavior: Clip.antiAlias,
elevation: 2.0,
child: GridTile(
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
},
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.broken_image, color: Colors.grey, size: 40);
},
),
if (isSelected)
Container(
color: Colors.black.withOpacity(0.6),
child: const Icon(Icons.check_circle, color: Colors.white, size: 40),
),
],
),
),
),
);
},
);
}
}

View File

@ -37,7 +37,7 @@ class RiverHomePage extends StatelessWidget {
SidebarItem(icon: Icons.pin_drop, label: "In-Situ Sampling", route: '/river/manual/in-situ'),
SidebarItem(icon: Icons.date_range, label: "Triennial Sampling", route: '/river/manual/triennial'),
SidebarItem(icon: Icons.article, label: "Data Log", route: '/river/manual/data-log'),
//SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'),
SidebarItem(icon: Icons.image, label: "Image Request", route: '/river/manual/image-request'),
],
),
SidebarItem(

View File

@ -841,6 +841,45 @@ class RiverApiService {
return _baseService.get(baseUrl, 'river/triennial-stations');
}
Future<Map<String, dynamic>> getRiverSamplingImages({
required int stationId,
required DateTime samplingDate,
required String samplingType,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final String dateStr = DateFormat('yyyy-MM-dd').format(samplingDate);
final String endpoint = 'river/manual/images-by-station?station_id=$stationId&date=$dateStr';
debugPrint("ApiService: Calling river image request API endpoint: $endpoint");
final response = await _baseService.get(baseUrl, endpoint);
// The backend now returns the data directly, so we just pass the response along.
return response;
}
Future<Map<String, dynamic>> sendImageRequestEmail({
required String recipientEmail,
required List<String> imageUrls,
required String stationName,
required String samplingDate,
}) async {
final baseUrl = await _serverConfigService.getActiveApiUrl();
final Map<String, String> fields = {
'recipientEmail': recipientEmail,
'imageUrls': jsonEncode(imageUrls),
'stationName': stationName,
'samplingDate': samplingDate,
};
return _baseService.postMultipart(
baseUrl: baseUrl,
endpoint: 'river/images/send-email', // Endpoint for river email requests
fields: fields,
files: {},
);
}
Future<Map<String, dynamic>> submitInSituSample({
required Map<String, String> formData,
required Map<String, File?> imageFiles,