fix river manual image request
This commit is contained in:
parent
37874a1eab
commit
077efa745d
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user