import 'dart:convert' show json; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../components/location_dialog.dart'; import '../components/my_elevated_button.dart'; import '../components/text_bold.dart'; import '../components/text_with_bold.dart'; import '../constants.dart'; import '../enumerations.dart'; import '../forms/skills_form.dart'; import '../models/language.dart'; import '../models/language_setting.dart'; import '../models/location.dart'; import '../services/auth/auth_service.dart'; import '../utils/helper_dialogs.dart'; import '../utils/math.dart'; class UserDataPage extends StatefulWidget { final bool isRegProcess; final bool isEditMode; const UserDataPage( {super.key, required this.isRegProcess, required this.isEditMode}); @override State createState() => _UserDataPageState(); } class _UserDataPageState extends State { MyLocation? _mainLocation; MyLocation? _secondaryLocation; List languagesList = []; final List _selectedLanguages = []; final FirebaseFirestore _firestore = FirebaseFirestore.instance; final AuthService _authService = AuthService(); int? _selectedYear; int? _yearFromDb; Gender genderView = Gender.none; int _genderFromDb = 0; List _languagesFromDb = []; MyLocation? _mainLocationFromDb; MyLocation? _secondaryLocationFromDb; bool _secondLocationExists = false; bool _isLanguageListExpanded = false; static const int _initialLanguageListItemCount = 5; @override void initState() { super.initState(); // load settings from database _fetchSettings(); } Future _fetchSettings() async { try { String currentUserId = _authService.getCurrentUser()!.uid; // Fetch user document fields (email, uid, gender, ...) from database DocumentSnapshot userSnapshot = await _firestore .collection(Constants.dbCollectionUsers) .doc(currentUserId) .get(); if (!userSnapshot.exists || userSnapshot.data() == null) { return; // should not happen, user entry should exist } Map userFields = userSnapshot.data() as Map; // Extract gender and birth year if (userFields.containsKey(Constants.dbFieldUsersGender)) { _genderFromDb = userSnapshot[Constants.dbFieldUsersGender]; } if (userFields.containsKey(Constants.dbFieldUsersYearBorn)) { _yearFromDb = userSnapshot[Constants.dbFieldUsersYearBorn]; } // Fetch locations QuerySnapshot locationSnapshot = await _firestore .collection(Constants.dbCollectionUsers) .doc(currentUserId) .collection(Constants.dbCollectionLocations) .get(); MyLocation userLoc; for (var doc in locationSnapshot.docs) { userLoc = MyLocation( street: doc[Constants.dbFieldLocationStreet], country: doc[Constants.dbFieldLocationCountry], administrativeArea: doc[Constants.dbFieldLocationArea], locality: doc[Constants.dbFieldLocationLocality], subLocality: doc[Constants.dbFieldLocationSubLocality], postalCode: doc[Constants.dbFieldLocationPostalCode], latitude: doc[Constants.dbFieldLocationLatitude], longitude: doc[Constants.dbFieldLocationLongitude], ); if (doc.id == Constants.dbDocMainLocation) { _mainLocationFromDb = userLoc; } else if (doc.id == Constants.dbDocSecondLocation) { _secondaryLocationFromDb = userLoc; _secondLocationExists = true; } } // Fetch languages QuerySnapshot languagesSnapshot = await _firestore .collection(Constants.dbCollectionUsers) .doc(currentUserId) .collection(Constants.dbCollectionLanguages) .get(); List userLanguages = []; for (var doc in languagesSnapshot.docs) { //languages.add(Language.fromDocument(doc)); userLanguages.add(Language( code: doc.id, // aka doc['code'], name: doc['name'], nativeName: doc['nativeName'], iconFile: doc['iconFile'], )); } setState(() { genderView = Gender.values[_genderFromDb]; _selectedYear = _yearFromDb; _languagesFromDb = userLanguages; _mainLocation = _mainLocationFromDb; _secondaryLocation = _secondaryLocationFromDb; }); // Load data from JSON file when the widget initializes loadLanguagesFromJson(); } catch (error) { _showSnackBar('Error fetching settings: $error'); } } /// Loads the languages to display Future loadLanguagesFromJson() async { // Load JSON data from assets String jsonData = await rootBundle.loadString(Constants.pathLanguagesJson); // Parse JSON into a list of objects List jsonList = await json.decode(jsonData); // Create LanguageSetting objects from JSON data languagesList = jsonList.map((item) { Language language = Language( code: item['code'], name: item['name'], nativeName: item['nativeName'], iconFile: item['iconFile'], ); return LanguageSetting( language: language, isSelected: _languagesFromDb.any( (language) => language.code == item['code'], ), ); }).toList(); // Update the UI after loading data setState(() { if (_selectedLanguages.isEmpty) { _selectedLanguages.addAll(_languagesFromDb); } }); } Future _saveUserData(BuildContext context) async { try { String currentUserID = _authService.getCurrentUser()!.uid; // Get references to the current users Firebase collections DocumentReference userRef = _firestore.collection(Constants.dbCollectionUsers).doc(currentUserID); CollectionReference languagesRef = userRef.collection(Constants.dbCollectionLanguages); CollectionReference locationsRef = userRef.collection(Constants.dbCollectionLocations); if (_selectedYear != _yearFromDb) { await userRef.update( {Constants.dbFieldUsersYearBorn: _selectedYear}, ); // update local value _yearFromDb = _selectedYear; } // Update Gender in database - only if value has changed if (_genderFromDb != genderView.index) { await userRef.update( {Constants.dbFieldUsersGender: genderView.index}, ); // update local value _genderFromDb = genderView.index; } // Save locations if (_mainLocation == null || _mainLocation != _mainLocationFromDb) { if (_mainLocation != null) { await locationsRef .doc(Constants.dbDocMainLocation) .set(_mainLocation!.toMap()); } else { _showSnackBar('No location selected'); return false; } } if (_secondaryLocation != _secondaryLocationFromDb) { if (_secondaryLocation != null) { await locationsRef .doc(Constants.dbDocSecondLocation) .set(_secondaryLocation!.toMap()) .then((value) => { // update local values _secondaryLocationFromDb = _secondaryLocation, _secondLocationExists = true }); } else if (_secondLocationExists) { // secondLocationExists but is null here -> delete secondary in DB await locationsRef.doc(Constants.dbDocSecondLocation).delete().then( (doc) => { // update local values _secondaryLocationFromDb = null, _secondLocationExists = false }, onError: (e) => _showSnackBar('Error updating document: $e'), ); } } // Save Languages - only if selected values changed if (_selectedLanguages.isEmpty) { _showSnackBar('No language selected'); return false; } else if (_languagesFromDb.length != _selectedLanguages.length || _selectedLanguages .any((element) => !_languagesFromDb.contains(element))) { // Loop through each Language object and save it to the collection for (int i = 0; i < _selectedLanguages.length; i++) { // Convert Language object to a map and add the map to the collection, // using .doc(myID).set() instead of .add() to avoid duplicates. await languagesRef .doc(_selectedLanguages[i].code) .set(_selectedLanguages[i].toMap()); } // update local variable (only if selection is not empty) _languagesFromDb.clear(); _languagesFromDb.addAll(_selectedLanguages); // List to store language codes from the provided list List languageCodes = _selectedLanguages.map((language) => language.code).toList(); // Clean up languages that were not part of the provided list await _deleteUnusedDocuments(languagesRef, languageCodes); } return true; } catch (e) { _showSnackBar(e.toString()); return false; } } /// Deletes documents from collection that are not part of the provided list Future _deleteUnusedDocuments( CollectionReference refCollection, List idsToKeep) async { // Fetch the existing documents from the database QuerySnapshot snapshot = await refCollection.get(); // Loop through each document in the collection for (QueryDocumentSnapshot doc in snapshot.docs) { String documentId = doc.id; // If the language code is not in the provided list, delete the document if (!idsToKeep.contains(documentId)) { await doc.reference.delete(); } } } void _updateSelectedGender(Set newSelection) { setState(() { // By default there is only a single segment that can be // selected at one time, so its value is always the first // item in the selected set. genderView = newSelection.first; }); } // Function to show location dialog void _showLocationDialog(bool isPrimary) { showDialog( context: context, builder: (BuildContext context) { return LocationDialog( onLocationSelected: (location) { setState(() { if (isPrimary) { _mainLocation = location; } else { _secondaryLocation = location; } }); }, ); }, ); } /// Method to remove secondary location void _removeSecondaryLocation() { setState(() { _secondaryLocation = null; }); } void _saveButtonClicked(BuildContext context) async { bool success = await _saveUserData(context); if (context.mounted) { if (success) { if (widget.isRegProcess) { Navigator.push( context, MaterialPageRoute( builder: (context) => SkillsForm( isRegProcess: widget.isRegProcess, skillsSought: false, isEditMode: false, ), ), ); } else { if (widget.isEditMode == true) { // pass data back to caller Map locations = { if (_mainLocation != null) Constants.dbDocMainLocation: _mainLocation, if (_secondaryLocation != null) Constants.dbDocSecondLocation: _secondaryLocation, }; Navigator.pop(context, { Constants.dbFieldUsersYearBorn: _selectedYear, Constants.dbFieldUsersGender: genderView, Constants.dbCollectionLanguages: _languagesFromDb, Constants.dbCollectionLocations: locations, }); } else { Navigator.pop(context); } } } else { _showSnackBar('Failed to save user data.'); } } } @override Widget build(BuildContext context) { if (languagesList.isEmpty) { return const Center(child: CircularProgressIndicator()); } else { return Scaffold( appBar: AppBar( title: Text( '${widget.isRegProcess ? 'Personal Details' : 'Edit your details'} '), centerTitle: true, actions: [ if (widget.isEditMode && !widget.isRegProcess) IconButton( onPressed: () { _saveButtonClicked(context); }, icon: const Icon(Icons.save), ) ], ), body: Padding( padding: const EdgeInsets.all(16.0), child: ListView( children: [ if (widget.isRegProcess) ...[ const SizedBox(height: 10), const TextBold( text: 'Please fill in the following fields to proceed with the registration process.', ), const SizedBox(height: 20), const Divider(), ], const SizedBox(height: 10), const TextBold(text: 'Location'), const SizedBox(height: 20), // Display selected main location const Text( 'Main Location:', style: TextStyle(fontWeight: FontWeight.bold), ), if (_mainLocation != null) ...[ Text(_mainLocation!.toString()), const SizedBox(height: 10), ] else ...[ const Text('n/a'), const SizedBox(height: 10), ], // Button to set main location Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Center( child: ElevatedButton.icon( icon: _mainLocation != null ? const Icon(Icons.edit_location_alt_outlined) : const Icon(Icons.location_on_outlined), label: Text(_mainLocation != null ? 'Edit Main Location' : 'Set Main Location'), onPressed: () { _showLocationDialog(true); }, ), ), ], ), const SizedBox(height: 20), // Display selected secondary location if (_secondaryLocation != null) ...[ const Text( 'Secondary Location:', style: TextStyle(fontWeight: FontWeight.bold), ), Text(_secondaryLocation!.toString()), const SizedBox(height: 10), ], Row( mainAxisAlignment: MainAxisAlignment.start, children: [ if (_mainLocation != null) ...[ // Button to set secondary location Center( child: ElevatedButton.icon( icon: _secondaryLocation != null ? const Icon(Icons.edit_location_alt_outlined) : const Icon(Icons.add_location), onPressed: () { _showLocationDialog(false); }, label: Text(_secondaryLocation != null ? 'Edit' : 'Add location'), ), ), ], const SizedBox(width: 10), // Display selected secondary location or remove button if (_secondaryLocation != null) ...[ const SizedBox(height: 12), Center( child: ElevatedButton.icon( onPressed: _removeSecondaryLocation, label: const Text('Remove'), icon: const Icon(Icons.wrong_location_outlined), ), ), ], ], ), const SizedBox(height: 20), const TextWithBold( boldText: 'Age ', trailingText: ' (optional)', boldSize: 18, trailingSize: 12, ), Row( children: [ const Padding(padding: EdgeInsets.symmetric(horizontal: 8)), Text(_selectedYear != null ? '${calcAge(_selectedYear)} years old' : 'not specified'), const SizedBox(width: 20), DropdownMenu( initialSelection: _selectedYear, onSelected: (int? newValue) { setState(() { _selectedYear = newValue; }); }, dropdownMenuEntries: List.generate(50, (index) { return DropdownMenuEntry( value: DateTime.now().year - 16 - index, label: '${DateTime.now().year - 16 - index}', ); }), label: const Text('birth year'), ), ], ), const SizedBox(height: 20), const TextWithBold( boldText: 'Gender ', trailingText: ' (optional)', boldSize: 18, trailingSize: 12, ), Align( alignment: Alignment.centerLeft, child: SegmentedButton( style: SegmentedButton.styleFrom( selectedBackgroundColor: Colors.blue, ), segments: const >[ ButtonSegment( value: Gender.none, label: Text('none'), ), ButtonSegment( value: Gender.male, label: Text('male'), ), ButtonSegment( value: Gender.female, label: Text('female'), ), ButtonSegment( value: Gender.divers, label: Text('diverse'), ), ], selected: {genderView}, showSelectedIcon: false, onSelectionChanged: _updateSelectedGender, ), ), const SizedBox(height: 20), const Divider(), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ TextBold( text: 'Language: (${_selectedLanguages.length} selected)', ), _buildExpandCollapseButton(), ], ), ..._buildLanguageList(), const Divider(), Padding( padding: const EdgeInsets.all(8.0), child: MyElevatedButton( child: Text( widget.isRegProcess ? 'Save and continue' : 'Save', ), onPressed: () { _saveButtonClicked(context); }, ), ), ], ), ), ); } } List _buildLanguageList() { List displayedLanguages = _isLanguageListExpanded ? languagesList : languagesList.take(_initialLanguageListItemCount).toList(); return displayedLanguages.map(buildSingleCheckbox).toList(); } Widget _buildExpandCollapseButton() { if (languagesList.length <= _initialLanguageListItemCount) { return Container(); } return TextButton( onPressed: () { setState(() { _isLanguageListExpanded = !_isLanguageListExpanded; }); }, child: Text( _isLanguageListExpanded ? 'Show less' : 'Show full list', ), ); } Widget buildSingleCheckbox(LanguageSetting languageSetting) => buildCheckbox( languageSetting: languageSetting, onClicked: () { setState(() { final newValue = !languageSetting.isSelected; languageSetting.isSelected = newValue; if (languageSetting.isSelected && !_selectedLanguages.contains(languageSetting.language)) { _selectedLanguages.add(languageSetting.language); } else if (languageSetting.isSelected == false) { _selectedLanguages.removeWhere( (element) => element.code == languageSetting.language.code); } }); }, ); Widget buildCheckbox({ required LanguageSetting languageSetting, required VoidCallback onClicked, }) => ListTile( selected: languageSetting.isSelected, selectedColor: Colors.blue, onTap: onClicked, leading: SizedBox( width: 48, height: 32, child: SvgPicture.asset( languageSetting.language.iconFile, ), ), title: TextWithBold( boldText: languageSetting.language.nativeName, trailingText: ' / ${languageSetting.language.name}', ), trailing: Checkbox( value: languageSetting.isSelected, onChanged: (value) => onClicked(), ), ); void _showSnackBar(String message) { showErrorSnackBar(context, message); } }