diff --git a/lib/screens/river/manual/river_manual_image_request.dart b/lib/screens/river/manual/river_manual_image_request.dart index 0a27ccc..88ffc43 100644 --- a/lib/screens/river/manual/river_manual_image_request.dart +++ b/lib/screens/river/manual/river_manual_image_request.dart @@ -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 createState() => _RiverManualImageRequestState(); + Widget build(BuildContext context) { + return const RiverImageRequestScreen(); + } } -class _RiverManualImageRequestState extends State { - XFile? _image; - final picker = ImagePicker(); - final _descriptionController = TextEditingController(); - Future _pickImage() async { - final pickedFile = await picker.pickImage(source: ImageSource.camera); - setState(() => _image = pickedFile); +class RiverImageRequestScreen extends StatefulWidget { + const RiverImageRequestScreen({super.key}); + + @override + State createState() => _RiverImageRequestScreenState(); +} + +class _RiverImageRequestScreenState extends State { + final _formKey = GlobalKey(); + final _dateController = TextEditingController(); + + final String _selectedSamplingType = 'In-Situ Sampling'; + + String? _selectedStateName; + String? _selectedBasinName; + Map? _selectedStation; + DateTime? _selectedDate; + + List _statesList = []; + List _basinsForState = []; + List> _stationsForBasin = []; + + bool _isLoading = false; + List _imageUrls = []; + final Set _selectedImageUrls = {}; + + @override + void initState() { + super.initState(); + _initializeStationFilters(); + } + + @override + void dispose() { + _dateController.dispose(); + super.dispose(); + } + + void _initializeStationFilters() { + final auth = Provider.of(context, listen: false); + final allStations = auth.riverManualStations ?? []; + if (allStations.isNotEmpty) { + final states = allStations.map((s) => s['state_name'] as String?).whereType().toSet().toList(); + states.sort(); + setState(() { + _statesList = states; + }); + } + } + + Future _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 _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(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 fetchedUrls = List.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 _showEmailDialog() async { + final emailController = TextEditingController(); + final dialogFormKey = GlobalKey(); + + return showDialog( + 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: [ + 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 _sendEmailRequestToServer(String toEmail) async { + final apiService = Provider.of(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( + 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(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().toSet().toList() : []; + basins.sort(); + _basinsForState = basins; + _stationsForBasin = []; + }); }, - child: Text("Submit Request"), + validator: (val) => val == null ? "State is required" : null, ), + const SizedBox(height: 16), + + // Basin Dropdown + DropdownSearch( + 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(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>( + 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), + ), + ], + ), + ), + ), + ); + }, + ); + } } \ No newline at end of file diff --git a/lib/screens/river/river_home_page.dart b/lib/screens/river/river_home_page.dart index c3ad9e8..568ae09 100644 --- a/lib/screens/river/river_home_page.dart +++ b/lib/screens/river/river_home_page.dart @@ -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( diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index b06e8db..5e29235 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -841,6 +841,45 @@ class RiverApiService { return _baseService.get(baseUrl, 'river/triennial-stations'); } + Future> 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> sendImageRequestEmail({ + required String recipientEmail, + required List imageUrls, + required String stationName, + required String samplingDate, + }) async { + final baseUrl = await _serverConfigService.getActiveApiUrl(); + final Map 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> submitInSituSample({ required Map formData, required Map imageFiles,