diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fef59f --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# MindLink + +MindLInk is a robust, customizable communication tool designed to support the functional development and communication of children with cerebral palsy. + +## Description + +This application has been designed for use by organizations working with children with cerebral palsy. It features an admin view that allows access to the children's data, customizable expedients and a customizable communicator that adjusts to the unique capabilities and needs of each child. + +The application offers a range of communication tools, including Visual Board, Switches, Portable Communicator, and more. These tools have been integrated into one place to facilitate access and customization. + +## Installation + +To install the application, follow the steps below: + +1. Clone this repository on your local machine using `https://github.com/davidmartinezhi/MindLink.git`. +2. `cd` into the cloned repository directory. +3. If you have not installed Swift and SwiftUI on your machine, install it following the instructions on its official website. +4. Open the project in Xcode and click on the Run button. + +## Usage + +- To access the communicator, select the "Access Base Communicator" option from the main menu. +- To edit the communicator, select the "Edit Base Communicator" option from the main menu. +- You can customize the communicator for each child, adjusting the communication tools according to the child's capabilities and needs. +- The admin view allows access and manage the children's data. + +## Contributing + +Contributions are always welcome! Please read the contribution guidelines first. + +## License + +MIT + +## Contact + +If you have any questions or suggestions, feel free to open an issue on this repository. diff --git a/nuevoamanecer.xcodeproj/project.pbxproj b/nuevoamanecer.xcodeproj/project.pbxproj index 091b352..ced6f77 100644 --- a/nuevoamanecer.xcodeproj/project.pbxproj +++ b/nuevoamanecer.xcodeproj/project.pbxproj @@ -7,6 +7,84 @@ objects = { /* Begin PBXBuildFile section */ + A9594A0F2A2842BF0063C0D7 /* ImagePicker_BACKUP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9594A0E2A2842BF0063C0D7 /* ImagePicker_BACKUP.swift */; }; + A9594A112A2842DC0063C0D7 /* FirebaseStorage_BACKUP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9594A102A2842DC0063C0D7 /* FirebaseStorage_BACKUP.swift */; }; + A9594A132A28438B0063C0D7 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = A9594A122A28438B0063C0D7 /* FirebaseStorage */; }; + A9CEFE7A2A2A9B1900BD6E58 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = A9CEFE792A2A9B1900BD6E58 /* SDWebImageSwiftUI */; }; + BC014F102A91801F0002140A /* AppLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC014F0F2A91801F0002140A /* AppLock.swift */; }; + BC0479172AD4E76E00FD18C8 /* UserImageEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0479162AD4E76E00FD18C8 /* UserImageEditView.swift */; }; + BC1132522A95466C0080F294 /* NavigationPathWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1132512A95466C0080F294 /* NavigationPathWrapper.swift */; }; + BC1132542A954E380080F294 /* NavigationDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1132532A954E380080F294 /* NavigationDestination.swift */; }; + BC1207722ABFF70E0098A527 /* UserWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1207712ABFF70E0098A527 /* UserWrapper.swift */; }; + BC2AF1DF2A6B9993002A822C /* PageOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2AF1DE2A6B9993002A822C /* PageOptionsView.swift */; }; + BC3B46B82ABE712F0034BDA9 /* UserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3B46B72ABE712F0034BDA9 /* UserViewModel.swift */; }; + BC3B46BA2ABF9E000034BDA9 /* AdminStringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC3B46B92ABF9E000034BDA9 /* AdminStringExtensions.swift */; }; + BC4DFE292AC346B10082C631 /* UserManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4DFE282AC346B10082C631 /* UserManagement.swift */; }; + BC4DFE2B2AC347420082C631 /* UserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4DFE2A2AC347420082C631 /* UserView.swift */; }; + BC4DFE2D2AC3C4530082C631 /* DualTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4DFE2C2AC3C4530082C631 /* DualTextFieldView.swift */; }; + BC4DFE312AC3C78B0082C631 /* DualChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC4DFE302AC3C78B0082C631 /* DualChoiceView.swift */; }; + BC553CAE2A3A7120002A2EA2 /* CustomConfirmAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC553CAD2A3A7120002A2EA2 /* CustomConfirmAlert.swift */; }; + BC58AB672AD27A3E0037ACDE /* PasswordInputTextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC58AB662AD27A3E0037ACDE /* PasswordInputTextFieldView.swift */; }; + BC58AB692AD28DCE0037ACDE /* PasswordInputWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC58AB682AD28DCE0037ACDE /* PasswordInputWindowView.swift */; }; + BC58AB6F2AD395120037ACDE /* UserMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC58AB6E2AD395120037ACDE /* UserMethods.swift */; }; + BC62E17C2AD604AC0049EAC2 /* UserManagementExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC62E17B2AD604AC0049EAC2 /* UserManagementExtensions.swift */; }; + BC6490292ADEC51E00870FF1 /* UserOperationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6490282ADEC51E00870FF1 /* UserOperationData.swift */; }; + BC6E07EB2AE2D8C0008AFF6E /* ValidationListDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6E07EA2AE2D8C0008AFF6E /* ValidationListDisplayView.swift */; }; + BC6E07ED2AE2D906008AFF6E /* PasswordValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6E07EC2AE2D906008AFF6E /* PasswordValidator.swift */; }; + BC884A762AAACC7000E83553 /* AdminArrayExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC884A752AAACC7000E83553 /* AdminArrayExtensions.swift */; }; + BC8F7E2F2A37FFBD008E97B2 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E012A37FFBD008E97B2 /* ImagePicker.swift */; }; + BC8F7E302A37FFBD008E97B2 /* FirebaseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E022A37FFBD008E97B2 /* FirebaseStorage.swift */; }; + BC8F7E312A37FFBD008E97B2 /* LockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E052A37FFBD008E97B2 /* LockView.swift */; }; + BC8F7E322A37FFBD008E97B2 /* SwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E062A37FFBD008E97B2 /* SwitchView.swift */; }; + BC8F7E332A37FFBD008E97B2 /* SingleCommunicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E082A37FFBD008E97B2 /* SingleCommunicator.swift */; }; + BC8F7E342A37FFBD008E97B2 /* Communicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E092A37FFBD008E97B2 /* Communicator.swift */; }; + BC8F7E352A37FFBD008E97B2 /* DoubleCommunicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E0A2A37FFBD008E97B2 /* DoubleCommunicator.swift */; }; + BC8F7E362A37FFBD008E97B2 /* VoiceSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E0C2A37FFBD008E97B2 /* VoiceSettingView.swift */; }; + BC8F7E372A37FFBD008E97B2 /* PictogramViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E0F2A37FFBD008E97B2 /* PictogramViewModel.swift */; }; + BC8F7E382A37FFBD008E97B2 /* CategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E102A37FFBD008E97B2 /* CategoryViewModel.swift */; }; + BC8F7E392A37FFBD008E97B2 /* ColorPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E122A37FFBD008E97B2 /* ColorPickerView.swift */; }; + BC8F7E3A2A37FFBD008E97B2 /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E132A37FFBD008E97B2 /* TextFieldView.swift */; }; + BC8F7E3B2A37FFBD008E97B2 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E142A37FFBD008E97B2 /* SearchBarView.swift */; }; + BC8F7E3C2A37FFBD008E97B2 /* XOverCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E152A37FFBD008E97B2 /* XOverCircleView.swift */; }; + BC8F7E3D2A37FFBD008E97B2 /* ButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E162A37FFBD008E97B2 /* ButtonView.swift */; }; + BC8F7E3E2A37FFBD008E97B2 /* PictogramEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E182A37FFBD008E97B2 /* PictogramEditor.swift */; }; + BC8F7E3F2A37FFBD008E97B2 /* CategoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E1A2A37FFBD008E97B2 /* CategoryModel.swift */; }; + BC8F7E402A37FFBD008E97B2 /* PictogramModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E1B2A37FFBD008E97B2 /* PictogramModel.swift */; }; + BC8F7E412A37FFBD008E97B2 /* CustomAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E1D2A37FFBD008E97B2 /* CustomAlert.swift */; }; + BC8F7E422A37FFBD008E97B2 /* ColorMaker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E1F2A37FFBD008E97B2 /* ColorMaker.swift */; }; + BC8F7E432A37FFBD008E97B2 /* SortedArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E202A37FFBD008E97B2 /* SortedArray.swift */; }; + BC8F7E442A37FFBD008E97B2 /* PictogramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E222A37FFBD008E97B2 /* PictogramView.swift */; }; + BC8F7E452A37FFBD008E97B2 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E232A37FFBD008E97B2 /* CategoryPickerView.swift */; }; + BC8F7E462A37FFBD008E97B2 /* DropDownCategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E242A37FFBD008E97B2 /* DropDownCategoryPicker.swift */; }; + BC8F7E472A37FFBD008E97B2 /* PictogramGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E252A37FFBD008E97B2 /* PictogramGridView.swift */; }; + BC8F7E482A37FFBD008E97B2 /* PictogramEditorWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E262A37FFBD008E97B2 /* PictogramEditorWindowView.swift */; }; + BC8F7E492A37FFBD008E97B2 /* CategoryEditorWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E272A37FFBD008E97B2 /* CategoryEditorWindowView.swift */; }; + BC8F7E4A2A37FFBD008E97B2 /* PageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E2A2A37FFBD008E97B2 /* PageViewModel.swift */; }; + BC8F7E4B2A37FFBD008E97B2 /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E2C2A37FFBD008E97B2 /* Album.swift */; }; + BC8F7E4C2A37FFBD008E97B2 /* PageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8F7E2E2A37FFBD008E97B2 /* PageModel.swift */; }; + BC8FE0A12A736A8100BE7ABC /* BoardCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8FE0A02A736A8100BE7ABC /* BoardCache.swift */; }; + BC91CB232AA445300078C780 /* LongPressButtonWithImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC91CB222AA445300078C780 /* LongPressButtonWithImageView.swift */; }; + BC93FD9C2A9B160700853E68 /* PictogramSearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC93FD9B2A9B160700853E68 /* PictogramSearchBarView.swift */; }; + BC93FD9F2A9B19D300853E68 /* PictogramStringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC93FD9E2A9B19D300853E68 /* PictogramStringExtensions.swift */; }; + BC9FFBD42A95AF45008B6B2B /* MarkedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC9FFBD32A95AF45008B6B2B /* MarkedScrollView.swift */; }; + BCAF20E12ACF05D4006DB43F /* VoiceSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAF20E02ACF05D4006DB43F /* VoiceSetting.swift */; }; + BCAF20E32ACF06CA006DB43F /* VoiceSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAF20E22ACF06CA006DB43F /* VoiceSettingViewModel.swift */; }; + BCB1B7092A42BA6E00C01B15 /* PageThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB1B7082A42BA6E00C01B15 /* PageThumbnail.swift */; }; + BCB1B70B2A42BA7600C01B15 /* PageDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB1B70A2A42BA7600C01B15 /* PageDisplay.swift */; }; + BCB1B70D2A42BB3100C01B15 /* PageEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB1B70C2A42BB3100C01B15 /* PageEdit.swift */; }; + BCB1B70F2A42BB6A00C01B15 /* PageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB1B70E2A42BB6A00C01B15 /* PageBoardView.swift */; }; + BCB1B7112A42BBD100C01B15 /* PictogramPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB1B7102A42BBD100C01B15 /* PictogramPickerView.swift */; }; + BCB518BD2ACCCACF003FDFE4 /* ImagePlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB518BC2ACCCACF003FDFE4 /* ImagePlaceholderView.swift */; }; + BCB518BF2ACCDF09003FDFE4 /* ChangeIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB518BE2ACCDF09003FDFE4 /* ChangeIndicatorView.swift */; }; + BCBC2F3D2A39A23F009CFD1C /* ButtonWithImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC2F3C2A39A23F009CFD1C /* ButtonWithImageView.swift */; }; + BCC50CE82AE09E4200367F26 /* NewUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC50CE72AE09E4200367F26 /* NewUserView.swift */; }; + BCD0B7862A4553C000E83CA6 /* EditPictogramHolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0B7852A4553C000E83CA6 /* EditPictogramHolderView.swift */; }; + BCD0B7882A45557000E83CA6 /* DisplayPictogramHolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0B7872A45557000E83CA6 /* DisplayPictogramHolderView.swift */; }; + BCD0B78A2A457BDD00E83CA6 /* PictogramPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0B7892A457BDD00E83CA6 /* PictogramPlaceholderView.swift */; }; + BCD0B78C2A45878E00E83CA6 /* DoublePictogramPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD0B78B2A45878E00E83CA6 /* DoublePictogramPickerView.swift */; }; + BCDB34C42A4933070011562C /* PictogramScaleModifierView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDB34C32A4933070011562C /* PictogramScaleModifierView.swift */; }; + BCEA4D6D2AA02717000C1517 /* PictogramGridArrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCEA4D6C2AA02717000C1517 /* PictogramGridArrowView.swift */; }; + BCF62E142AC712F600697CFC /* EditPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCF62E132AC712F600697CFC /* EditPanelView.swift */; }; C2207EFC2A184E3000F31578 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = C2207EFB2A184E3000F31578 /* Kingfisher */; }; C2207EFE2A1C04A500F31578 /* AddPatientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2207EFD2A1C04A500F31578 /* AddPatientView.swift */; }; C2207F002A1F1AF400F31578 /* Note.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2207EFF2A1F1AF300F31578 /* Note.swift */; }; @@ -15,6 +93,9 @@ C2207F062A2162E600F31578 /* EditPatientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2207F052A2162E600F31578 /* EditPatientView.swift */; }; C2207F082A252D2900F31578 /* DeletePatientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2207F072A252D2800F31578 /* DeletePatientView.swift */; }; C2207F0E2A268C5A00F31578 /* EditNoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2207F0D2A268C5A00F31578 /* EditNoteView.swift */; }; + C22A9A682A9432790087AF1F /* HelpersStringValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C22A9A672A9432790087AF1F /* HelpersStringValidation.swift */; }; + C22ECD452A33E32200DF8A06 /* CommunicatorMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C22ECD442A33E32200DF8A06 /* CommunicatorMenuView.swift */; }; + C22ECD482A380C4600DF8A06 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = C22ECD472A380C4600DF8A06 /* FirebaseFirestoreSwift */; }; C235FDAD2A18106E005412A7 /* AdminView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235FDAC2A18106E005412A7 /* AdminView.swift */; }; C235FDAF2A181080005412A7 /* PatientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235FDAE2A181080005412A7 /* PatientView.swift */; }; C235FDB12A1810C3005412A7 /* PatientsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235FDB02A1810C3005412A7 /* PatientsViewModel.swift */; }; @@ -31,14 +112,93 @@ C2BEDB062A0DA77A005398F9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C2BEDB052A0DA77A005398F9 /* GoogleService-Info.plist */; }; C2BEDB0C2A1410AC005398F9 /* DismissView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB0B2A1410AC005398F9 /* DismissView.swift */; }; C2BEDB0F2A141262005398F9 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = C2BEDB0E2A141262005398F9 /* FirebaseAuth */; }; - C2BEDB1A2A14507C005398F9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB192A14507C005398F9 /* HomeView.swift */; }; C2BEDB632A157B12005398F9 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB622A157B12005398F9 /* LoginView.swift */; }; C2BEDB652A157B22005398F9 /* RegisterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB642A157B22005398F9 /* RegisterView.swift */; }; C2BEDB672A157B72005398F9 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB662A157B72005398F9 /* User.swift */; }; C2BEDB6D2A15867D005398F9 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BEDB6C2A15867D005398F9 /* AuthViewModel.swift */; }; + C2CF66872A2E8AAE005A7338 /* AdminNav.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2CF66862A2E8AAE005A7338 /* AdminNav.swift */; }; + C2CF668D2A2FE308005A7338 /* AdminMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2CF668C2A2FE308005A7338 /* AdminMenuView.swift */; }; + C2CF668F2A302982005A7338 /* PatientCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2CF668E2A302982005A7338 /* PatientCardView.swift */; }; + C2CF66912A302A7F005A7338 /* NoteCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2CF66902A302A7F005A7338 /* NoteCardView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + A9594A0E2A2842BF0063C0D7 /* ImagePicker_BACKUP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePicker_BACKUP.swift; sourceTree = ""; }; + A9594A102A2842DC0063C0D7 /* FirebaseStorage_BACKUP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseStorage_BACKUP.swift; sourceTree = ""; }; + BC014F0F2A91801F0002140A /* AppLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLock.swift; sourceTree = ""; }; + BC0479162AD4E76E00FD18C8 /* UserImageEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserImageEditView.swift; sourceTree = ""; }; + BC1132512A95466C0080F294 /* NavigationPathWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationPathWrapper.swift; sourceTree = ""; }; + BC1132532A954E380080F294 /* NavigationDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationDestination.swift; sourceTree = ""; }; + BC1207712ABFF70E0098A527 /* UserWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserWrapper.swift; sourceTree = ""; }; + BC2AF1DE2A6B9993002A822C /* PageOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageOptionsView.swift; sourceTree = ""; }; + BC3B46B72ABE712F0034BDA9 /* UserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewModel.swift; sourceTree = ""; }; + BC3B46B92ABF9E000034BDA9 /* AdminStringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminStringExtensions.swift; sourceTree = ""; }; + BC4DFE282AC346B10082C631 /* UserManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserManagement.swift; sourceTree = ""; }; + BC4DFE2A2AC347420082C631 /* UserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserView.swift; sourceTree = ""; }; + BC4DFE2C2AC3C4530082C631 /* DualTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DualTextFieldView.swift; sourceTree = ""; }; + BC4DFE302AC3C78B0082C631 /* DualChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DualChoiceView.swift; sourceTree = ""; }; + BC553CAD2A3A7120002A2EA2 /* CustomConfirmAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomConfirmAlert.swift; sourceTree = ""; }; + BC58AB662AD27A3E0037ACDE /* PasswordInputTextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordInputTextFieldView.swift; sourceTree = ""; }; + BC58AB682AD28DCE0037ACDE /* PasswordInputWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordInputWindowView.swift; sourceTree = ""; }; + BC58AB6E2AD395120037ACDE /* UserMethods.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMethods.swift; sourceTree = ""; }; + BC62E17B2AD604AC0049EAC2 /* UserManagementExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserManagementExtensions.swift; sourceTree = ""; }; + BC6490282ADEC51E00870FF1 /* UserOperationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserOperationData.swift; sourceTree = ""; }; + BC6E07EA2AE2D8C0008AFF6E /* ValidationListDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationListDisplayView.swift; sourceTree = ""; }; + BC6E07EC2AE2D906008AFF6E /* PasswordValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordValidator.swift; sourceTree = ""; }; + BC884A752AAACC7000E83553 /* AdminArrayExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminArrayExtensions.swift; sourceTree = ""; }; + BC8F7E012A37FFBD008E97B2 /* ImagePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + BC8F7E022A37FFBD008E97B2 /* FirebaseStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FirebaseStorage.swift; sourceTree = ""; }; + BC8F7E052A37FFBD008E97B2 /* LockView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LockView.swift; sourceTree = ""; }; + BC8F7E062A37FFBD008E97B2 /* SwitchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchView.swift; sourceTree = ""; }; + BC8F7E082A37FFBD008E97B2 /* SingleCommunicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleCommunicator.swift; sourceTree = ""; }; + BC8F7E092A37FFBD008E97B2 /* Communicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Communicator.swift; sourceTree = ""; }; + BC8F7E0A2A37FFBD008E97B2 /* DoubleCommunicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DoubleCommunicator.swift; sourceTree = ""; }; + BC8F7E0C2A37FFBD008E97B2 /* VoiceSettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoiceSettingView.swift; sourceTree = ""; }; + BC8F7E0F2A37FFBD008E97B2 /* PictogramViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramViewModel.swift; sourceTree = ""; }; + BC8F7E102A37FFBD008E97B2 /* CategoryViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryViewModel.swift; sourceTree = ""; }; + BC8F7E122A37FFBD008E97B2 /* ColorPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorPickerView.swift; sourceTree = ""; }; + BC8F7E132A37FFBD008E97B2 /* TextFieldView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = ""; }; + BC8F7E142A37FFBD008E97B2 /* SearchBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchBarView.swift; sourceTree = ""; }; + BC8F7E152A37FFBD008E97B2 /* XOverCircleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XOverCircleView.swift; sourceTree = ""; }; + BC8F7E162A37FFBD008E97B2 /* ButtonView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ButtonView.swift; sourceTree = ""; }; + BC8F7E182A37FFBD008E97B2 /* PictogramEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramEditor.swift; sourceTree = ""; }; + BC8F7E1A2A37FFBD008E97B2 /* CategoryModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryModel.swift; sourceTree = ""; }; + BC8F7E1B2A37FFBD008E97B2 /* PictogramModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramModel.swift; sourceTree = ""; }; + BC8F7E1D2A37FFBD008E97B2 /* CustomAlert.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomAlert.swift; sourceTree = ""; }; + BC8F7E1F2A37FFBD008E97B2 /* ColorMaker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColorMaker.swift; sourceTree = ""; }; + BC8F7E202A37FFBD008E97B2 /* SortedArray.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SortedArray.swift; sourceTree = ""; }; + BC8F7E222A37FFBD008E97B2 /* PictogramView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramView.swift; sourceTree = ""; }; + BC8F7E232A37FFBD008E97B2 /* CategoryPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = ""; }; + BC8F7E242A37FFBD008E97B2 /* DropDownCategoryPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropDownCategoryPicker.swift; sourceTree = ""; }; + BC8F7E252A37FFBD008E97B2 /* PictogramGridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramGridView.swift; sourceTree = ""; }; + BC8F7E262A37FFBD008E97B2 /* PictogramEditorWindowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PictogramEditorWindowView.swift; sourceTree = ""; }; + BC8F7E272A37FFBD008E97B2 /* CategoryEditorWindowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryEditorWindowView.swift; sourceTree = ""; }; + BC8F7E2A2A37FFBD008E97B2 /* PageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageViewModel.swift; sourceTree = ""; }; + BC8F7E2C2A37FFBD008E97B2 /* Album.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = ""; }; + BC8F7E2E2A37FFBD008E97B2 /* PageModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageModel.swift; sourceTree = ""; }; + BC8FE0A02A736A8100BE7ABC /* BoardCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardCache.swift; sourceTree = ""; }; + BC91CB222AA445300078C780 /* LongPressButtonWithImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPressButtonWithImageView.swift; sourceTree = ""; }; + BC93FD9B2A9B160700853E68 /* PictogramSearchBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramSearchBarView.swift; sourceTree = ""; }; + BC93FD9E2A9B19D300853E68 /* PictogramStringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramStringExtensions.swift; sourceTree = ""; }; + BC9FFBD32A95AF45008B6B2B /* MarkedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkedScrollView.swift; sourceTree = ""; }; + BCAF20E02ACF05D4006DB43F /* VoiceSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSetting.swift; sourceTree = ""; }; + BCAF20E22ACF06CA006DB43F /* VoiceSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSettingViewModel.swift; sourceTree = ""; }; + BCB1B7082A42BA6E00C01B15 /* PageThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageThumbnail.swift; sourceTree = ""; }; + BCB1B70A2A42BA7600C01B15 /* PageDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageDisplay.swift; sourceTree = ""; }; + BCB1B70C2A42BB3100C01B15 /* PageEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageEdit.swift; sourceTree = ""; }; + BCB1B70E2A42BB6A00C01B15 /* PageBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageBoardView.swift; sourceTree = ""; }; + BCB1B7102A42BBD100C01B15 /* PictogramPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramPickerView.swift; sourceTree = ""; }; + BCB518BC2ACCCACF003FDFE4 /* ImagePlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePlaceholderView.swift; sourceTree = ""; }; + BCB518BE2ACCDF09003FDFE4 /* ChangeIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeIndicatorView.swift; sourceTree = ""; }; + BCBC2F3C2A39A23F009CFD1C /* ButtonWithImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonWithImageView.swift; sourceTree = ""; }; + BCC50CE72AE09E4200367F26 /* NewUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewUserView.swift; sourceTree = ""; }; + BCD0B7852A4553C000E83CA6 /* EditPictogramHolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictogramHolderView.swift; sourceTree = ""; }; + BCD0B7872A45557000E83CA6 /* DisplayPictogramHolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPictogramHolderView.swift; sourceTree = ""; }; + BCD0B7892A457BDD00E83CA6 /* PictogramPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramPlaceholderView.swift; sourceTree = ""; }; + BCD0B78B2A45878E00E83CA6 /* DoublePictogramPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoublePictogramPickerView.swift; sourceTree = ""; }; + BCDB34C32A4933070011562C /* PictogramScaleModifierView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramScaleModifierView.swift; sourceTree = ""; }; + BCEA4D6C2AA02717000C1517 /* PictogramGridArrowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictogramGridArrowView.swift; sourceTree = ""; }; + BCF62E132AC712F600697CFC /* EditPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPanelView.swift; sourceTree = ""; }; C2207EFD2A1C04A500F31578 /* AddPatientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPatientView.swift; sourceTree = ""; }; C2207EFF2A1F1AF300F31578 /* Note.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Note.swift; sourceTree = ""; }; C2207F012A1F1B7E00F31578 /* NotesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesViewModel.swift; sourceTree = ""; }; @@ -46,6 +206,8 @@ C2207F052A2162E600F31578 /* EditPatientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPatientView.swift; sourceTree = ""; }; C2207F072A252D2800F31578 /* DeletePatientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletePatientView.swift; sourceTree = ""; }; C2207F0D2A268C5A00F31578 /* EditNoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditNoteView.swift; sourceTree = ""; }; + C22A9A672A9432790087AF1F /* HelpersStringValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpersStringValidation.swift; sourceTree = ""; }; + C22ECD442A33E32200DF8A06 /* CommunicatorMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunicatorMenuView.swift; sourceTree = ""; }; C235FDAC2A18106E005412A7 /* AdminView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminView.swift; sourceTree = ""; }; C235FDAE2A181080005412A7 /* PatientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientView.swift; sourceTree = ""; }; C235FDB02A1810C3005412A7 /* PatientsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientsViewModel.swift; sourceTree = ""; }; @@ -59,11 +221,14 @@ C2A8CABC2A0D9F580026DB96 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; C2BEDB052A0DA77A005398F9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; C2BEDB0B2A1410AC005398F9 /* DismissView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissView.swift; sourceTree = ""; }; - C2BEDB192A14507C005398F9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; C2BEDB622A157B12005398F9 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; C2BEDB642A157B22005398F9 /* RegisterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterView.swift; sourceTree = ""; }; C2BEDB662A157B72005398F9 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; C2BEDB6C2A15867D005398F9 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; + C2CF66862A2E8AAE005A7338 /* AdminNav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminNav.swift; sourceTree = ""; }; + C2CF668C2A2FE308005A7338 /* AdminMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminMenuView.swift; sourceTree = ""; }; + C2CF668E2A302982005A7338 /* PatientCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatientCardView.swift; sourceTree = ""; }; + C2CF66902A302A7F005A7338 /* NoteCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteCardView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -71,21 +236,404 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A9CEFE7A2A2A9B1900BD6E58 /* SDWebImageSwiftUI in Frameworks */, C2BEDB022A0DA53B005398F9 /* FirebaseAnalyticsSwift in Frameworks */, C29428022A15A8F000E814C9 /* FirebaseFirestore in Frameworks */, C2BEDB0F2A141262005398F9 /* FirebaseAuth in Frameworks */, C2BEDB002A0DA53B005398F9 /* FirebaseAnalytics in Frameworks */, C2207EFC2A184E3000F31578 /* Kingfisher in Frameworks */, + A9594A132A28438B0063C0D7 /* FirebaseStorage in Frameworks */, + C22ECD482A380C4600DF8A06 /* FirebaseFirestoreSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + BC014F0E2A9180090002140A /* MiscStructures */ = { + isa = PBXGroup; + children = ( + BC014F0F2A91801F0002140A /* AppLock.swift */, + ); + path = MiscStructures; + sourceTree = ""; + }; + BC1132502A9546550080F294 /* MiscStructures */ = { + isa = PBXGroup; + children = ( + BC1132512A95466C0080F294 /* NavigationPathWrapper.swift */, + BC1132532A954E380080F294 /* NavigationDestination.swift */, + BC1207712ABFF70E0098A527 /* UserWrapper.swift */, + ); + path = MiscStructures; + sourceTree = ""; + }; + BC4DFE232AC346190082C631 /* UserManagement */ = { + isa = PBXGroup; + children = ( + BC6490272ADEC4FF00870FF1 /* MiscStructures */, + BC62E17A2AD604910049EAC2 /* Extensions */, + BC58AB6D2AD395000037ACDE /* MiscMethods */, + BC4DFE272AC3465A0082C631 /* GenericViews */, + BC4DFE252AC346420082C631 /* Views */, + BC4DFE242AC346210082C631 /* ViewModels */, + BC4DFE262AC346490082C631 /* MainViews */, + ); + path = UserManagement; + sourceTree = ""; + }; + BC4DFE242AC346210082C631 /* ViewModels */ = { + isa = PBXGroup; + children = ( + BC3B46B72ABE712F0034BDA9 /* UserViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + BC4DFE252AC346420082C631 /* Views */ = { + isa = PBXGroup; + children = ( + BC4DFE2A2AC347420082C631 /* UserView.swift */, + BC58AB682AD28DCE0037ACDE /* PasswordInputWindowView.swift */, + BC0479162AD4E76E00FD18C8 /* UserImageEditView.swift */, + BCC50CE72AE09E4200367F26 /* NewUserView.swift */, + BC6E07EA2AE2D8C0008AFF6E /* ValidationListDisplayView.swift */, + ); + path = Views; + sourceTree = ""; + }; + BC4DFE262AC346490082C631 /* MainViews */ = { + isa = PBXGroup; + children = ( + BC4DFE282AC346B10082C631 /* UserManagement.swift */, + ); + path = MainViews; + sourceTree = ""; + }; + BC4DFE272AC3465A0082C631 /* GenericViews */ = { + isa = PBXGroup; + children = ( + BC4DFE2C2AC3C4530082C631 /* DualTextFieldView.swift */, + BC4DFE302AC3C78B0082C631 /* DualChoiceView.swift */, + BCF62E132AC712F600697CFC /* EditPanelView.swift */, + BCB518BE2ACCDF09003FDFE4 /* ChangeIndicatorView.swift */, + ); + path = GenericViews; + sourceTree = ""; + }; + BC58AB6D2AD395000037ACDE /* MiscMethods */ = { + isa = PBXGroup; + children = ( + BC58AB6E2AD395120037ACDE /* UserMethods.swift */, + ); + path = MiscMethods; + sourceTree = ""; + }; + BC62E17A2AD604910049EAC2 /* Extensions */ = { + isa = PBXGroup; + children = ( + BC62E17B2AD604AC0049EAC2 /* UserManagementExtensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + BC6490272ADEC4FF00870FF1 /* MiscStructures */ = { + isa = PBXGroup; + children = ( + BC6490282ADEC51E00870FF1 /* UserOperationData.swift */, + BC6E07EC2AE2D906008AFF6E /* PasswordValidator.swift */, + ); + path = MiscStructures; + sourceTree = ""; + }; + BC884A742AAACC5F00E83553 /* Extensions */ = { + isa = PBXGroup; + children = ( + BC884A752AAACC7000E83553 /* AdminArrayExtensions.swift */, + BC3B46B92ABF9E000034BDA9 /* AdminStringExtensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + BC8F7DFF2A37FFBD008E97B2 /* PictogramSection */ = { + isa = PBXGroup; + children = ( + BC8F7E032A37FFBD008E97B2 /* Communicator */, + BC8F7E0D2A37FFBD008E97B2 /* PictogramEditor */, + BC8F7E282A37FFBD008E97B2 /* Album */, + ); + path = PictogramSection; + sourceTree = ""; + }; + BC8F7E002A37FFBD008E97B2 /* ImageHandling */ = { + isa = PBXGroup; + children = ( + BC8F7E012A37FFBD008E97B2 /* ImagePicker.swift */, + BC8F7E022A37FFBD008E97B2 /* FirebaseStorage.swift */, + ); + path = ImageHandling; + sourceTree = ""; + }; + BC8F7E032A37FFBD008E97B2 /* Communicator */ = { + isa = PBXGroup; + children = ( + BC014F0E2A9180090002140A /* MiscStructures */, + BC8F7E042A37FFBD008E97B2 /* GenericViews */, + BC8F7E072A37FFBD008E97B2 /* MainViews */, + BC8F7E0B2A37FFBD008E97B2 /* Views */, + ); + path = Communicator; + sourceTree = ""; + }; + BC8F7E042A37FFBD008E97B2 /* GenericViews */ = { + isa = PBXGroup; + children = ( + BC8F7E052A37FFBD008E97B2 /* LockView.swift */, + BC8F7E062A37FFBD008E97B2 /* SwitchView.swift */, + ); + path = GenericViews; + sourceTree = ""; + }; + BC8F7E072A37FFBD008E97B2 /* MainViews */ = { + isa = PBXGroup; + children = ( + BC8F7E082A37FFBD008E97B2 /* SingleCommunicator.swift */, + BC8F7E092A37FFBD008E97B2 /* Communicator.swift */, + BC8F7E0A2A37FFBD008E97B2 /* DoubleCommunicator.swift */, + ); + path = MainViews; + sourceTree = ""; + }; + BC8F7E0B2A37FFBD008E97B2 /* Views */ = { + isa = PBXGroup; + children = ( + BC8F7E0C2A37FFBD008E97B2 /* VoiceSettingView.swift */, + ); + path = Views; + sourceTree = ""; + }; + BC8F7E0D2A37FFBD008E97B2 /* PictogramEditor */ = { + isa = PBXGroup; + children = ( + BC93FD9D2A9B19BD00853E68 /* Extensions */, + BC8F7E0E2A37FFBD008E97B2 /* ViewModels */, + BC8F7E112A37FFBD008E97B2 /* GenericViews */, + BC8F7E172A37FFBD008E97B2 /* MainViews */, + BC8F7E192A37FFBD008E97B2 /* Models */, + BC8F7E1C2A37FFBD008E97B2 /* MiscModifiers */, + BC8F7E1E2A37FFBD008E97B2 /* MiscStructures */, + BC8F7E212A37FFBD008E97B2 /* Views */, + ); + path = PictogramEditor; + sourceTree = ""; + }; + BC8F7E0E2A37FFBD008E97B2 /* ViewModels */ = { + isa = PBXGroup; + children = ( + BC8F7E0F2A37FFBD008E97B2 /* PictogramViewModel.swift */, + BC8F7E102A37FFBD008E97B2 /* CategoryViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + BC8F7E112A37FFBD008E97B2 /* GenericViews */ = { + isa = PBXGroup; + children = ( + BC8F7E122A37FFBD008E97B2 /* ColorPickerView.swift */, + BC8F7E132A37FFBD008E97B2 /* TextFieldView.swift */, + BC8F7E142A37FFBD008E97B2 /* SearchBarView.swift */, + BC8F7E152A37FFBD008E97B2 /* XOverCircleView.swift */, + BC8F7E162A37FFBD008E97B2 /* ButtonView.swift */, + BCBC2F3C2A39A23F009CFD1C /* ButtonWithImageView.swift */, + BC9FFBD32A95AF45008B6B2B /* MarkedScrollView.swift */, + BC91CB222AA445300078C780 /* LongPressButtonWithImageView.swift */, + ); + path = GenericViews; + sourceTree = ""; + }; + BC8F7E172A37FFBD008E97B2 /* MainViews */ = { + isa = PBXGroup; + children = ( + BC8F7E182A37FFBD008E97B2 /* PictogramEditor.swift */, + ); + path = MainViews; + sourceTree = ""; + }; + BC8F7E192A37FFBD008E97B2 /* Models */ = { + isa = PBXGroup; + children = ( + BC8F7E1A2A37FFBD008E97B2 /* CategoryModel.swift */, + BC8F7E1B2A37FFBD008E97B2 /* PictogramModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + BC8F7E1C2A37FFBD008E97B2 /* MiscModifiers */ = { + isa = PBXGroup; + children = ( + BC8F7E1D2A37FFBD008E97B2 /* CustomAlert.swift */, + BC553CAD2A3A7120002A2EA2 /* CustomConfirmAlert.swift */, + ); + path = MiscModifiers; + sourceTree = ""; + }; + BC8F7E1E2A37FFBD008E97B2 /* MiscStructures */ = { + isa = PBXGroup; + children = ( + BC8F7E1F2A37FFBD008E97B2 /* ColorMaker.swift */, + BC8F7E202A37FFBD008E97B2 /* SortedArray.swift */, + ); + path = MiscStructures; + sourceTree = ""; + }; + BC8F7E212A37FFBD008E97B2 /* Views */ = { + isa = PBXGroup; + children = ( + BC8F7E222A37FFBD008E97B2 /* PictogramView.swift */, + BC8F7E232A37FFBD008E97B2 /* CategoryPickerView.swift */, + BC8F7E242A37FFBD008E97B2 /* DropDownCategoryPicker.swift */, + BC8F7E252A37FFBD008E97B2 /* PictogramGridView.swift */, + BC8F7E262A37FFBD008E97B2 /* PictogramEditorWindowView.swift */, + BC8F7E272A37FFBD008E97B2 /* CategoryEditorWindowView.swift */, + BC93FD9B2A9B160700853E68 /* PictogramSearchBarView.swift */, + BCEA4D6C2AA02717000C1517 /* PictogramGridArrowView.swift */, + ); + path = Views; + sourceTree = ""; + }; + BC8F7E282A37FFBD008E97B2 /* Album */ = { + isa = PBXGroup; + children = ( + BCB1B7032A428AF900C01B15 /* Views */, + BC8F7E292A37FFBD008E97B2 /* ViewModels */, + BC8F7E2B2A37FFBD008E97B2 /* MainViews */, + BC8F7E2D2A37FFBD008E97B2 /* Models */, + ); + path = Album; + sourceTree = ""; + }; + BC8F7E292A37FFBD008E97B2 /* ViewModels */ = { + isa = PBXGroup; + children = ( + BC8F7E2A2A37FFBD008E97B2 /* PageViewModel.swift */, + BC8FE0A02A736A8100BE7ABC /* BoardCache.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + BC8F7E2B2A37FFBD008E97B2 /* MainViews */ = { + isa = PBXGroup; + children = ( + BCB1B70C2A42BB3100C01B15 /* PageEdit.swift */, + BCB1B70A2A42BA7600C01B15 /* PageDisplay.swift */, + BCB1B7082A42BA6E00C01B15 /* PageThumbnail.swift */, + BC8F7E2C2A37FFBD008E97B2 /* Album.swift */, + ); + path = MainViews; + sourceTree = ""; + }; + BC8F7E2D2A37FFBD008E97B2 /* Models */ = { + isa = PBXGroup; + children = ( + BC8F7E2E2A37FFBD008E97B2 /* PageModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + BC93FD9D2A9B19BD00853E68 /* Extensions */ = { + isa = PBXGroup; + children = ( + BC93FD9E2A9B19D300853E68 /* PictogramStringExtensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + BCB1B7032A428AF900C01B15 /* Views */ = { + isa = PBXGroup; + children = ( + BCB1B70E2A42BB6A00C01B15 /* PageBoardView.swift */, + BCB1B7102A42BBD100C01B15 /* PictogramPickerView.swift */, + BCD0B7852A4553C000E83CA6 /* EditPictogramHolderView.swift */, + BCD0B7872A45557000E83CA6 /* DisplayPictogramHolderView.swift */, + BCD0B7892A457BDD00E83CA6 /* PictogramPlaceholderView.swift */, + BCD0B78B2A45878E00E83CA6 /* DoublePictogramPickerView.swift */, + BCDB34C32A4933070011562C /* PictogramScaleModifierView.swift */, + BC2AF1DE2A6B9993002A822C /* PageOptionsView.swift */, + ); + path = Views; + sourceTree = ""; + }; + BCB518BB2ACCCAAC003FDFE4 /* GenericViews */ = { + isa = PBXGroup; + children = ( + BCB518BC2ACCCACF003FDFE4 /* ImagePlaceholderView.swift */, + BC58AB662AD27A3E0037ACDE /* PasswordInputTextFieldView.swift */, + ); + path = GenericViews; + sourceTree = ""; + }; + C22A9A692A94356C0087AF1F /* Helpers */ = { + isa = PBXGroup; + children = ( + C22A9A672A9432790087AF1F /* HelpersStringValidation.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + C22ECD3D2A33C3D900DF8A06 /* Admin */ = { + isa = PBXGroup; + children = ( + C22ECD3F2A33C3F300DF8A06 /* PatientProfile */, + C22ECD3E2A33C3E500DF8A06 /* AdminDashboard */, + ); + path = Admin; + sourceTree = ""; + }; + C22ECD3E2A33C3E500DF8A06 /* AdminDashboard */ = { + isa = PBXGroup; + children = ( + C2CF668C2A2FE308005A7338 /* AdminMenuView.swift */, + C2207EFD2A1C04A500F31578 /* AddPatientView.swift */, + C2CF668E2A302982005A7338 /* PatientCardView.swift */, + C235FDAC2A18106E005412A7 /* AdminView.swift */, + ); + path = AdminDashboard; + sourceTree = ""; + }; + C22ECD3F2A33C3F300DF8A06 /* PatientProfile */ = { + isa = PBXGroup; + children = ( + C235FDAE2A181080005412A7 /* PatientView.swift */, + C2207F032A206B4900F31578 /* AddNoteView.swift */, + C2207F0D2A268C5A00F31578 /* EditNoteView.swift */, + C2CF66902A302A7F005A7338 /* NoteCardView.swift */, + C2207F052A2162E600F31578 /* EditPatientView.swift */, + C2207F072A252D2800F31578 /* DeletePatientView.swift */, + ); + path = PatientProfile; + sourceTree = ""; + }; + C22ECD462A37B7A300DF8A06 /* Admin */ = { + isa = PBXGroup; + children = ( + BCB518BB2ACCCAAC003FDFE4 /* GenericViews */, + BC4DFE232AC346190082C631 /* UserManagement */, + BC884A742AAACC5F00E83553 /* Extensions */, + BC1132502A9546550080F294 /* MiscStructures */, + C22A9A692A94356C0087AF1F /* Helpers */, + C2BEDB602A157A93005398F9 /* ViewModel */, + C2BEDB5F2A157A8D005398F9 /* View */, + C2BEDB5E2A157A84005398F9 /* Model */, + C2BEDB0B2A1410AC005398F9 /* DismissView.swift */, + ); + path = Admin; + sourceTree = ""; + }; C29428042A15B3B100E814C9 /* Data */ = { isa = PBXGroup; children = ( C29428052A15B3C500E814C9 /* AuthenticationFirebaseDataSource.swift */, + A9594A102A2842DC0063C0D7 /* FirebaseStorage_BACKUP.swift */, ); path = Data; sourceTree = ""; @@ -110,14 +658,12 @@ C2A8CAB42A0D9F540026DB96 /* nuevoamanecer */ = { isa = PBXGroup; children = ( - C29428072A15B85500E814C9 /* ContentView.swift */, C29428042A15B3B100E814C9 /* Data */, - C2BEDB602A157A93005398F9 /* ViewModel */, - C2BEDB5F2A157A8D005398F9 /* View */, - C2BEDB5E2A157A84005398F9 /* Model */, - C2BEDB192A14507C005398F9 /* HomeView.swift */, - C2BEDB0B2A1410AC005398F9 /* DismissView.swift */, + BC8F7E002A37FFBD008E97B2 /* ImageHandling */, C2A8CAB52A0D9F540026DB96 /* nuevoamanecerApp.swift */, + C29428072A15B85500E814C9 /* ContentView.swift */, + BC8F7DFF2A37FFBD008E97B2 /* PictogramSection */, + C22ECD462A37B7A300DF8A06 /* Admin */, C2BEDB052A0DA77A005398F9 /* GoogleService-Info.plist */, C2A8CAB92A0D9F580026DB96 /* Assets.xcassets */, C2A8CABB2A0D9F580026DB96 /* Preview Content */, @@ -146,6 +692,7 @@ C235FDB22A1810EB005412A7 /* Patient.swift */, C2BEDB662A157B72005398F9 /* User.swift */, C2207EFF2A1F1AF300F31578 /* Note.swift */, + BCAF20E02ACF05D4006DB43F /* VoiceSetting.swift */, ); path = Model; sourceTree = ""; @@ -153,14 +700,11 @@ C2BEDB5F2A157A8D005398F9 /* View */ = { isa = PBXGroup; children = ( + C22ECD3D2A33C3D900DF8A06 /* Admin */, + C2CF66852A2E8A7F005A7338 /* Layout */, C2BEDB612A157AF5005398F9 /* Authentication */, - C235FDAC2A18106E005412A7 /* AdminView.swift */, - C235FDAE2A181080005412A7 /* PatientView.swift */, - C2207EFD2A1C04A500F31578 /* AddPatientView.swift */, - C2207F032A206B4900F31578 /* AddNoteView.swift */, - C2207F052A2162E600F31578 /* EditPatientView.swift */, - C2207F072A252D2800F31578 /* DeletePatientView.swift */, - C2207F0D2A268C5A00F31578 /* EditNoteView.swift */, + A9594A0E2A2842BF0063C0D7 /* ImagePicker_BACKUP.swift */, + C22ECD442A33E32200DF8A06 /* CommunicatorMenuView.swift */, ); path = View; sourceTree = ""; @@ -171,6 +715,7 @@ C2BEDB6C2A15867D005398F9 /* AuthViewModel.swift */, C235FDB02A1810C3005412A7 /* PatientsViewModel.swift */, C2207F012A1F1B7E00F31578 /* NotesViewModel.swift */, + BCAF20E22ACF06CA006DB43F /* VoiceSettingViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -185,6 +730,14 @@ path = Authentication; sourceTree = ""; }; + C2CF66852A2E8A7F005A7338 /* Layout */ = { + isa = PBXGroup; + children = ( + C2CF66862A2E8AAE005A7338 /* AdminNav.swift */, + ); + path = Layout; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -207,6 +760,9 @@ C2BEDB0E2A141262005398F9 /* FirebaseAuth */, C29428012A15A8F000E814C9 /* FirebaseFirestore */, C2207EFB2A184E3000F31578 /* Kingfisher */, + A9594A122A28438B0063C0D7 /* FirebaseStorage */, + A9CEFE792A2A9B1900BD6E58 /* SDWebImageSwiftUI */, + C22ECD472A380C4600DF8A06 /* FirebaseFirestoreSwift */, ); productName = nuevoamanecer; productReference = C2A8CAB22A0D9F540026DB96 /* nuevoamanecer.app */; @@ -239,6 +795,7 @@ packageReferences = ( C2BEDAFE2A0DA53B005398F9 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, C2207EFA2A184E3000F31578 /* XCRemoteSwiftPackageReference "Kingfisher" */, + A9CEFE782A2A9B1900BD6E58 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, ); productRefGroup = C2A8CAB32A0D9F540026DB96 /* Products */; projectDirPath = ""; @@ -267,27 +824,108 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BC91CB232AA445300078C780 /* LongPressButtonWithImageView.swift in Sources */, + BCB1B7092A42BA6E00C01B15 /* PageThumbnail.swift in Sources */, + BC1132522A95466C0080F294 /* NavigationPathWrapper.swift in Sources */, + BC8F7E4A2A37FFBD008E97B2 /* PageViewModel.swift in Sources */, + BC8F7E342A37FFBD008E97B2 /* Communicator.swift in Sources */, + BC8F7E442A37FFBD008E97B2 /* PictogramView.swift in Sources */, + BCD0B78A2A457BDD00E83CA6 /* PictogramPlaceholderView.swift in Sources */, C235FDAF2A181080005412A7 /* PatientView.swift in Sources */, + BC8F7E3C2A37FFBD008E97B2 /* XOverCircleView.swift in Sources */, + BC8F7E422A37FFBD008E97B2 /* ColorMaker.swift in Sources */, + BC8F7E472A37FFBD008E97B2 /* PictogramGridView.swift in Sources */, + BC8F7E412A37FFBD008E97B2 /* CustomAlert.swift in Sources */, + BC93FD9C2A9B160700853E68 /* PictogramSearchBarView.swift in Sources */, C2BEDB652A157B22005398F9 /* RegisterView.swift in Sources */, - C2BEDB1A2A14507C005398F9 /* HomeView.swift in Sources */, + BCB1B70D2A42BB3100C01B15 /* PageEdit.swift in Sources */, + BC8F7E362A37FFBD008E97B2 /* VoiceSettingView.swift in Sources */, C2207F022A1F1B7F00F31578 /* NotesViewModel.swift in Sources */, + BC58AB672AD27A3E0037ACDE /* PasswordInputTextFieldView.swift in Sources */, + BC8FE0A12A736A8100BE7ABC /* BoardCache.swift in Sources */, + BCAF20E32ACF06CA006DB43F /* VoiceSettingViewModel.swift in Sources */, + BC4DFE2D2AC3C4530082C631 /* DualTextFieldView.swift in Sources */, + BC62E17C2AD604AC0049EAC2 /* UserManagementExtensions.swift in Sources */, + C2CF66912A302A7F005A7338 /* NoteCardView.swift in Sources */, + BC2AF1DF2A6B9993002A822C /* PageOptionsView.swift in Sources */, + BCD0B7882A45557000E83CA6 /* DisplayPictogramHolderView.swift in Sources */, + BCB518BD2ACCCACF003FDFE4 /* ImagePlaceholderView.swift in Sources */, + BCD0B78C2A45878E00E83CA6 /* DoublePictogramPickerView.swift in Sources */, + A9594A112A2842DC0063C0D7 /* FirebaseStorage_BACKUP.swift in Sources */, C235FDAD2A18106E005412A7 /* AdminView.swift in Sources */, + BC8F7E332A37FFBD008E97B2 /* SingleCommunicator.swift in Sources */, + BC93FD9F2A9B19D300853E68 /* PictogramStringExtensions.swift in Sources */, + BCB518BF2ACCDF09003FDFE4 /* ChangeIndicatorView.swift in Sources */, + BC6E07ED2AE2D906008AFF6E /* PasswordValidator.swift in Sources */, + BCC50CE82AE09E4200367F26 /* NewUserView.swift in Sources */, C29428062A15B3C500E814C9 /* AuthenticationFirebaseDataSource.swift in Sources */, + BC8F7E392A37FFBD008E97B2 /* ColorPickerView.swift in Sources */, C2207F042A206B4900F31578 /* AddNoteView.swift in Sources */, + BC8F7E492A37FFBD008E97B2 /* CategoryEditorWindowView.swift in Sources */, C2BEDB0C2A1410AC005398F9 /* DismissView.swift in Sources */, + BCBC2F3D2A39A23F009CFD1C /* ButtonWithImageView.swift in Sources */, + C2CF668D2A2FE308005A7338 /* AdminMenuView.swift in Sources */, + BC6E07EB2AE2D8C0008AFF6E /* ValidationListDisplayView.swift in Sources */, + BC8F7E452A37FFBD008E97B2 /* CategoryPickerView.swift in Sources */, + BC4DFE312AC3C78B0082C631 /* DualChoiceView.swift in Sources */, + BC8F7E372A37FFBD008E97B2 /* PictogramViewModel.swift in Sources */, C2A8CAB62A0D9F540026DB96 /* nuevoamanecerApp.swift in Sources */, C2207EFE2A1C04A500F31578 /* AddPatientView.swift in Sources */, + BCAF20E12ACF05D4006DB43F /* VoiceSetting.swift in Sources */, + BCF62E142AC712F600697CFC /* EditPanelView.swift in Sources */, C2207F062A2162E600F31578 /* EditPatientView.swift in Sources */, + BC8F7E402A37FFBD008E97B2 /* PictogramModel.swift in Sources */, C29428082A15B85500E814C9 /* ContentView.swift in Sources */, + BC8F7E3D2A37FFBD008E97B2 /* ButtonView.swift in Sources */, + BCB1B70F2A42BB6A00C01B15 /* PageBoardView.swift in Sources */, C250AFC72A174B60008F5699 /* AuthView.swift in Sources */, + BCB1B70B2A42BA7600C01B15 /* PageDisplay.swift in Sources */, + BC8F7E3B2A37FFBD008E97B2 /* SearchBarView.swift in Sources */, C235FDB12A1810C3005412A7 /* PatientsViewModel.swift in Sources */, + C2CF668F2A302982005A7338 /* PatientCardView.swift in Sources */, + BC8F7E432A37FFBD008E97B2 /* SortedArray.swift in Sources */, + BC0479172AD4E76E00FD18C8 /* UserImageEditView.swift in Sources */, + BCD0B7862A4553C000E83CA6 /* EditPictogramHolderView.swift in Sources */, + BC9FFBD42A95AF45008B6B2B /* MarkedScrollView.swift in Sources */, + BC8F7E482A37FFBD008E97B2 /* PictogramEditorWindowView.swift in Sources */, + BC553CAE2A3A7120002A2EA2 /* CustomConfirmAlert.swift in Sources */, + BC1207722ABFF70E0098A527 /* UserWrapper.swift in Sources */, + BC1132542A954E380080F294 /* NavigationDestination.swift in Sources */, + BC8F7E322A37FFBD008E97B2 /* SwitchView.swift in Sources */, + BC8F7E2F2A37FFBD008E97B2 /* ImagePicker.swift in Sources */, + BCDB34C42A4933070011562C /* PictogramScaleModifierView.swift in Sources */, + BC884A762AAACC7000E83553 /* AdminArrayExtensions.swift in Sources */, + BCB1B7112A42BBD100C01B15 /* PictogramPickerView.swift in Sources */, C2207F002A1F1AF400F31578 /* Note.swift in Sources */, C2BEDB672A157B72005398F9 /* User.swift in Sources */, + BC8F7E3F2A37FFBD008E97B2 /* CategoryModel.swift in Sources */, C235FDB32A1810EB005412A7 /* Patient.swift in Sources */, + BC8F7E382A37FFBD008E97B2 /* CategoryViewModel.swift in Sources */, C2BEDB632A157B12005398F9 /* LoginView.swift in Sources */, + BC8F7E3A2A37FFBD008E97B2 /* TextFieldView.swift in Sources */, + BC014F102A91801F0002140A /* AppLock.swift in Sources */, + BC6490292ADEC51E00870FF1 /* UserOperationData.swift in Sources */, + BC4DFE292AC346B10082C631 /* UserManagement.swift in Sources */, C2BEDB6D2A15867D005398F9 /* AuthViewModel.swift in Sources */, + BC3B46B82ABE712F0034BDA9 /* UserViewModel.swift in Sources */, + BC58AB692AD28DCE0037ACDE /* PasswordInputWindowView.swift in Sources */, + BC8F7E312A37FFBD008E97B2 /* LockView.swift in Sources */, + A9594A0F2A2842BF0063C0D7 /* ImagePicker_BACKUP.swift in Sources */, + BC8F7E4B2A37FFBD008E97B2 /* Album.swift in Sources */, + BC4DFE2B2AC347420082C631 /* UserView.swift in Sources */, + BC8F7E302A37FFBD008E97B2 /* FirebaseStorage.swift in Sources */, + BC8F7E352A37FFBD008E97B2 /* DoubleCommunicator.swift in Sources */, C2207F0E2A268C5A00F31578 /* EditNoteView.swift in Sources */, + C22A9A682A9432790087AF1F /* HelpersStringValidation.swift in Sources */, + BC8F7E3E2A37FFBD008E97B2 /* PictogramEditor.swift in Sources */, + BC8F7E4C2A37FFBD008E97B2 /* PageModel.swift in Sources */, C2207F082A252D2900F31578 /* DeletePatientView.swift in Sources */, + BC3B46BA2ABF9E000034BDA9 /* AdminStringExtensions.swift in Sources */, + C22ECD452A33E32200DF8A06 /* CommunicatorMenuView.swift in Sources */, + C2CF66872A2E8AAE005A7338 /* AdminNav.swift in Sources */, + BC8F7E462A37FFBD008E97B2 /* DropDownCategoryPicker.swift in Sources */, + BCEA4D6D2AA02717000C1517 /* PictogramGridArrowView.swift in Sources */, + BC58AB6F2AD395120037ACDE /* UserMethods.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -419,11 +1057,13 @@ DEVELOPMENT_TEAM = 633HKT35S8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = MindLink; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -448,11 +1088,13 @@ DEVELOPMENT_TEAM = 633HKT35S8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = MindLink; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -490,6 +1132,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + A9CEFE782A2A9B1900BD6E58 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; C2207EFA2A184E3000F31578 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher"; @@ -509,11 +1159,26 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + A9594A122A28438B0063C0D7 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = C2BEDAFE2A0DA53B005398F9 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; + A9CEFE792A2A9B1900BD6E58 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = A9CEFE782A2A9B1900BD6E58 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; C2207EFB2A184E3000F31578 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = C2207EFA2A184E3000F31578 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + C22ECD472A380C4600DF8A06 /* FirebaseFirestoreSwift */ = { + isa = XCSwiftPackageProductDependency; + package = C2BEDAFE2A0DA53B005398F9 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestoreSwift; + }; C29428012A15A8F000E814C9 /* FirebaseFirestore */ = { isa = XCSwiftPackageProductDependency; package = C2BEDAFE2A0DA53B005398F9 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/nuevoamanecer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/nuevoamanecer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9b8fea7..fbf036f 100644 --- a/nuevoamanecer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/nuevoamanecer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,24 @@ "version" : "2.2.0" } }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "c51ba84499268ea3020e6aee9e229c0f56b9d924", + "version" : "5.16.0" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git", + "state" : { + "revision" : "e837c37d45449fbd3b4745c10c5b5274e73edead", + "version" : "2.2.3" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", diff --git a/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/alumno.xcuserdatad/UserInterfaceState.xcuserstate b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/alumno.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..93018fe Binary files /dev/null and b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/alumno.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/emilio.xcuserdatad/UserInterfaceState.xcuserstate b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/emilio.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..b3818b7 Binary files /dev/null and b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/emilio.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/gerardomartinez.xcuserdatad/UserInterfaceState.xcuserstate b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/gerardomartinez.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..f60f9b7 Binary files /dev/null and b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/gerardomartinez.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/josearguellesrios.xcuserdatad/UserInterfaceState.xcuserstate b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/josearguellesrios.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..5608e8e Binary files /dev/null and b/nuevoamanecer.xcodeproj/project.xcworkspace/xcuserdata/josearguellesrios.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nuevoamanecer.xcodeproj/xcuserdata/alumno.xcuserdatad/xcschemes/xcschememanagement.plist b/nuevoamanecer.xcodeproj/xcuserdata/alumno.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..42c1562 --- /dev/null +++ b/nuevoamanecer.xcodeproj/xcuserdata/alumno.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,35 @@ + + + + + SchemeUserState + + Promises (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + Promises (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + Promises (Playground).xcscheme + + isShown + + orderHint + 1 + + nuevoamanecer.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..3817a66 --- /dev/null +++ b/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcschemes/xcschememanagement.plist b/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..42c1562 --- /dev/null +++ b/nuevoamanecer.xcodeproj/xcuserdata/emilio.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,35 @@ + + + + + SchemeUserState + + Promises (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + Promises (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + Promises (Playground).xcscheme + + isShown + + orderHint + 1 + + nuevoamanecer.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/nuevoamanecer.xcodeproj/xcuserdata/gerardomartinez.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/nuevoamanecer.xcodeproj/xcuserdata/gerardomartinez.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 2f05065..fdccc2f 100644 --- a/nuevoamanecer.xcodeproj/xcuserdata/gerardomartinez.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/nuevoamanecer.xcodeproj/xcuserdata/gerardomartinez.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -7,33 +7,17 @@ - - - - + startingLineNumber = "319" + endingLineNumber = "319" + landmarkName = "body" + landmarkType = "24"> diff --git a/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..e280470 --- /dev/null +++ b/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcschemes/xcschememanagement.plist b/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..42c1562 --- /dev/null +++ b/nuevoamanecer.xcodeproj/xcuserdata/josearguellesrios.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,35 @@ + + + + + SchemeUserState + + Promises (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + Promises (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + Promises (Playground).xcscheme + + isShown + + orderHint + 1 + + nuevoamanecer.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/nuevoamanecer/.DS_Store b/nuevoamanecer/.DS_Store new file mode 100644 index 0000000..7fdc474 Binary files /dev/null and b/nuevoamanecer/.DS_Store differ diff --git a/nuevoamanecer/Admin/DismissView.swift b/nuevoamanecer/Admin/DismissView.swift new file mode 100644 index 0000000..c6c4177 --- /dev/null +++ b/nuevoamanecer/Admin/DismissView.swift @@ -0,0 +1,8 @@ +// +// DismissView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 16/05/23. +// + + diff --git a/nuevoamanecer/Admin/Extensions/AdminArrayExtensions.swift b/nuevoamanecer/Admin/Extensions/AdminArrayExtensions.swift new file mode 100644 index 0000000..0c6fa1a --- /dev/null +++ b/nuevoamanecer/Admin/Extensions/AdminArrayExtensions.swift @@ -0,0 +1,29 @@ +// +// ArrayExtensions.swift +// nuevoamanecer +// +// Created by emilio on 07/09/23. +// + +import Foundation + +extension Array where Self.Element: Equatable { + // Cambia la posición del elemento que se encuentra en el índice from. Tras el cambio, su nueva posición es to + // en el arreglo. + mutating func moveItem(from: Int, to: Int) -> Void { // Asume indices no negativos. + let removedItem: Self.Element = self.remove(at: from) + if to <= self.count { + self.insert(removedItem, at: to) + } else { + self.append(removedItem) + } + } + + func getElementSafely(index: Index) -> Self.Element? { + return self.indices.contains(index) ? self[index] : nil + } + + mutating func replaceItem(with item: Self.Element, where predicate: (Self.Element)->Bool) throws -> Void { + self[self.firstIndex(where: predicate)!] = item + } +} diff --git a/nuevoamanecer/Admin/Extensions/AdminStringExtensions.swift b/nuevoamanecer/Admin/Extensions/AdminStringExtensions.swift new file mode 100644 index 0000000..29194ca --- /dev/null +++ b/nuevoamanecer/Admin/Extensions/AdminStringExtensions.swift @@ -0,0 +1,26 @@ +// +// StringExtensions.swift +// nuevoamanecer +// +// Created by emilio on 23/09/23. +// + +import Foundation + +extension String { + func splitAtWhitespaces() -> [String] { + return self.split(separator: " ").map(String.init) + } + + func trimAtEnds() -> String { + return self.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func removeWhitespaces() -> String { + return self.replacingOccurrences(of: "\\s", with: "", options: .regularExpression, range: nil) + } + + func isValidEmail() -> Bool { + return self.contains(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/) + } +} diff --git a/nuevoamanecer/Admin/GenericViews/ImagePlaceholderView.swift b/nuevoamanecer/Admin/GenericViews/ImagePlaceholderView.swift new file mode 100644 index 0000000..209bebf --- /dev/null +++ b/nuevoamanecer/Admin/GenericViews/ImagePlaceholderView.swift @@ -0,0 +1,26 @@ +// +// ImagePlaceholderView.swift +// nuevoamanecer +// +// Created by emilio on 03/10/23. +// + +import SwiftUI + +struct ImagePlaceholderView: View { + var firstName: String + var lastName: String + var radius: CGFloat = 100 + var fontSize: CGFloat = 28 + + var body: some View { + Text(firstName.prefix(1) + lastName.prefix(1)) + .textCase(.uppercase) + .font(.system(size: fontSize)) + .fontWeight(.bold) + .frame(width: radius, height: radius) + .background(Color(.systemGray3)) + .foregroundColor(.white) + .clipShape(Circle()) + } +} diff --git a/nuevoamanecer/Admin/GenericViews/PasswordInputTextFieldView.swift b/nuevoamanecer/Admin/GenericViews/PasswordInputTextFieldView.swift new file mode 100644 index 0000000..47c8052 --- /dev/null +++ b/nuevoamanecer/Admin/GenericViews/PasswordInputTextFieldView.swift @@ -0,0 +1,54 @@ +// +// PasswordInputTextField.swift +// nuevoamanecer +// +// Created by emilio on 07/10/23. +// + +import SwiftUI + +enum Field: Hashable { + case plain + case secure +} + +struct PasswordInputTextFieldView: View { + @Binding var password: String + @State private var showPassword: Bool = false + @FocusState private var inFocus: Field? + + var body: some View { + ZStack (alignment: .trailing) { + if showPassword { + TextField("Contraseña", text: $password) + .modifier(PasswordInputTextFieldBaseStyle()) + .focused($inFocus, equals: .plain) + } else { + SecureField("Contraseña", text: $password) + .modifier(PasswordInputTextFieldBaseStyle()) + .focused($inFocus, equals: .secure) + } + + Button() { + showPassword.toggle() + inFocus = showPassword ? .plain : .secure + } label: { + Image(systemName: showPassword ? "eye" : "eye.slash") + .padding(.vertical) + .padding(.trailing) + } + } + } +} + +struct PasswordInputTextFieldBaseStyle: ViewModifier { + func body(content: Content) -> some View { + content + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + } +} diff --git a/nuevoamanecer/Admin/Helpers/HelpersStringValidation.swift b/nuevoamanecer/Admin/Helpers/HelpersStringValidation.swift new file mode 100644 index 0000000..48e45bf --- /dev/null +++ b/nuevoamanecer/Admin/Helpers/HelpersStringValidation.swift @@ -0,0 +1,100 @@ +// +// Helpers.swift +// nuevoamanecer +// +// Created by David Gerardo Martínez on 21/08/23. +// + +import Foundation + + +/* + input: String + output: Bool + description: Función para validar si el string no constiene white spaces +*/ +func isValidInputNoWhiteSpaces(input: String) -> Bool { + let pattern = "^\\s*$" // Expresión regular que coincide con cadenas que contienen solo espacios en blanco + let regex = try? NSRegularExpression(pattern: pattern) + let matches = regex?.matches(in: input, options: [], range: NSRange(location: 0, length: input.count)) + return matches?.isEmpty ?? true // Devuelve true si no hay coincidencias (es decir, la entrada no está vacía y no contiene solo espacios en blanco) +} + + +/* + input: String + output: Bool + description: Función para validar si un nombre es válido utilizando una expresión regular + */ +func isValidName(name: String) -> Bool { + // Elimina los espacios en blanco al final del nombre + let trimmedName = name.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression) + + // Expresión regular para validar el nombre + let pattern = "^[A-Za-zÀ-ÖØ-öø-ÿ]+(?: [A-Za-zÀ-ÖØ-öø-ÿ]+)*$" + let regex = try? NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: trimmedName.utf16.count) + return regex?.firstMatch(in: trimmedName, options: [], range: range) != nil +} + +/* + input: String + output: Bool + description: Devuelve true si hay coincidencias (es decir, la entrada tiene espacios en blanco al principio) + */ +func hasLeadingWhitespace(input: String) -> Bool { + let pattern = "^\\s" + let regex = try? NSRegularExpression(pattern: pattern) + let matches = regex?.matches(in: input, options: [], range: NSRange(location: 0, length: input.count)) + return !(matches?.isEmpty ?? true) +} + + +/* + input: String + output: Bool + description: Función para validar si un string no tiene espacio en blanco al inicio y solo contiene letras y numeros + */ +func isValidOnlyCharAndNumbers(input: String) -> Bool { + let pattern = "^[a-zA-Z0-9áéíóúÁÉÍÓÚüÜñÑ][a-zA-Z0-9áéíóúÁÉÍÓÚüÜñÑ\\s]*$" + let regex = try? NSRegularExpression(pattern: pattern) + let matches = regex?.matches(in: input, options: [], range: NSRange(location: 0, length: input.count)) + return !(matches?.isEmpty ?? true) // Devuelve true si hay coincidencias (es decir, la entrada es válida) +} + + + + +/* + input: String + output: Bool + description: Función para validar si una fecha de cumpleaños es válida, siendo almenos con una fecha pcon un mes previo + */ +func isValidBirthDate(birthDate: Date) -> Bool { + let currentDate = Date() + let calendar = Calendar.current + let oneMonthAgo = calendar.date(byAdding: .month, value: -1, to: currentDate)! + + // Verifica si la fecha de nacimiento está en el futuro + if birthDate.compare(currentDate) == .orderedDescending { + return false + } + + // Verifica si la fecha de nacimiento está en el mes previo + if birthDate.compare(oneMonthAgo) == .orderedDescending { + return false + } + + return true +} + + +/* + input: String + output: String + description: Función para remover espacios en blanco al final de un string + */ +func removeTrailingWhitespace(from string: String) -> String { + return string.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression) +} + diff --git a/nuevoamanecer/Admin/MiscStructures/NavigationDestination.swift b/nuevoamanecer/Admin/MiscStructures/NavigationDestination.swift new file mode 100644 index 0000000..7b16fb1 --- /dev/null +++ b/nuevoamanecer/Admin/MiscStructures/NavigationDestination.swift @@ -0,0 +1,28 @@ +// +// IdWrappers.swift +// nuevoamanecer +// +// Created by emilio on 22/08/23. +// + +import Foundation +import SwiftUI + +/* +enum ViewType { + case singleCommunicator, doubleCommunicator, basePictogramEditor, userPictogramEditor, login, adminDash, patient +} + */ + +struct NavigationDestination: Hashable { + let id: UUID = UUID() + let content: Content + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func==(lhs: NavigationDestination, rhs: NavigationDestination) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/nuevoamanecer/Admin/MiscStructures/NavigationPathWrapper.swift b/nuevoamanecer/Admin/MiscStructures/NavigationPathWrapper.swift new file mode 100644 index 0000000..7d96106 --- /dev/null +++ b/nuevoamanecer/Admin/MiscStructures/NavigationPathWrapper.swift @@ -0,0 +1,27 @@ +// +// NavigationPathWrapper.swift +// nuevoamanecer +// +// Created by emilio on 22/08/23. +// + +import Foundation +import SwiftUI + +class NavigationPathWrapper: ObservableObject { + @Published var path: NavigationPath = NavigationPath() + + func push(_ data: V) -> Void { + self.path.append(data) + } + + func pop() -> Void { + if path.count > 0 { + self.path.removeLast(1) + } + } + + func returnToRoot() -> Void { + self.path.removeLast(self.path.count) + } +} diff --git a/nuevoamanecer/Admin/MiscStructures/UserWrapper.swift b/nuevoamanecer/Admin/MiscStructures/UserWrapper.swift new file mode 100644 index 0000000..3812644 --- /dev/null +++ b/nuevoamanecer/Admin/MiscStructures/UserWrapper.swift @@ -0,0 +1,45 @@ +// +// UserWrapper.swift +// nuevoamanecer +// +// Created by emilio on 23/09/23. +// + +import Foundation +import SwiftUI + +class UserWrapper: ObservableObject { + @Published private var user: User? + + var id: String? { + self.user?.id + } + + var name: String? { + self.user?.name + } + + var email: String? { + self.user?.email + } + + var isAdmin: Bool? { + self.user?.isAdmin + } + + var image: String? { + self.user?.image + } + + init(user: User? = nil){ + self.user = user + } + + func getUser() -> User? { + return self.user + } + + func setUser(user: User) -> Void { + self.user = user + } +} diff --git a/nuevoamanecer/Model/Note.swift b/nuevoamanecer/Admin/Model/Note.swift similarity index 77% rename from nuevoamanecer/Model/Note.swift rename to nuevoamanecer/Admin/Model/Note.swift index 7da8fbd..f61a0c5 100644 --- a/nuevoamanecer/Model/Note.swift +++ b/nuevoamanecer/Admin/Model/Note.swift @@ -7,7 +7,7 @@ import Foundation -struct Note: Hashable, Codable, Identifiable { +struct Note: Hashable, Codable, Identifiable, Equatable { //var id: ObjectIdentifier let id: String let patientId: String @@ -15,4 +15,5 @@ struct Note: Hashable, Codable, Identifiable { var title: String var text: String var date: Date + var tag: String } diff --git a/nuevoamanecer/Model/Patient.swift b/nuevoamanecer/Admin/Model/Patient.swift similarity index 58% rename from nuevoamanecer/Model/Patient.swift rename to nuevoamanecer/Admin/Model/Patient.swift index 8a542da..e9ffc11 100644 --- a/nuevoamanecer/Model/Patient.swift +++ b/nuevoamanecer/Admin/Model/Patient.swift @@ -7,7 +7,7 @@ import Foundation -struct Patient: Hashable{ +struct Patient: Hashable, Codable, Identifiable { var id: String let firstName: String let lastName: String @@ -17,4 +17,9 @@ struct Patient: Hashable{ let cognitiveLevel: String let image: String let notes: [String] + let identificador: String + + func buildPatientTitle() -> String { + return firstName + " " + lastName.prefix(upTo: lastName.firstIndex(of: " ") ?? lastName.endIndex) + } } diff --git a/nuevoamanecer/Admin/Model/User.swift b/nuevoamanecer/Admin/Model/User.swift new file mode 100644 index 0000000..6023fea --- /dev/null +++ b/nuevoamanecer/Admin/Model/User.swift @@ -0,0 +1,61 @@ +// +// User.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 17/05/23. +// + +import Foundation +import FirebaseFirestoreSwift + +struct User: Identifiable, Codable, Hashable, Equatable { + @DocumentID var id: String? + var name: String + var email: String + var isAdmin: Bool + var image: String? + + func isValidUser() -> Bool { + var result: Bool = true + result = result && self.hasValidName() + result = result && self.hasValidEmail() + return result + } + + func hasValidName() -> Bool { + return self.name.count < 21 && self.name.count > 3 && self.name.contains(/^[A-Za-z]+(?: [A-Za-z]+)*$/) + // Contiene por lo menos 4 caracteres. + // Contiene no más de 20 caracters. + // No termina o inicia con espacios. + // Contiene solamente caracteres alfabéticos. + // Entre caracteres alfabéticos hay no más de un espacio. + } + + func hasValidEmail() -> Bool { + return self.email.isValidEmail() + } + + func toDict() -> [String:Any] { + var userDict: [String:Any] = ["name": name, "email": email, "isAdmin": isAdmin] + + if image != nil { + userDict["image"] = image! + } + + return userDict + } + + static func newEmptyUser() -> User { + return User(name: "", email: "", isAdmin: false) + } + + static func ==(lhs: User, rhs: User) -> Bool { + var result: Bool = true + result = result && lhs.id == rhs.id + result = result && lhs.name == rhs.name + result = result && lhs.email == rhs.email + result = result && lhs.isAdmin == rhs.isAdmin + result = result && lhs.image == rhs.image + return result + } +} diff --git a/nuevoamanecer/Admin/Model/VoiceSetting.swift b/nuevoamanecer/Admin/Model/VoiceSetting.swift new file mode 100644 index 0000000..a7106d2 --- /dev/null +++ b/nuevoamanecer/Admin/Model/VoiceSetting.swift @@ -0,0 +1,21 @@ +// +// VoiceConfiguration.swift +// nuevoamanecer +// +// Created by emilio on 05/10/23. +// + +import Foundation +import FirebaseFirestoreSwift + +struct VoiceSetting: Identifiable, Codable, Equatable { + @DocumentID var id: String? + var talkingSpeed: String + var voiceGender: String + var voiceAge: String + var patientId: String? + + static func defaultVoiceSetting(patientId: String? = nil) -> VoiceSetting { + return VoiceSetting(talkingSpeed: "Normal", voiceGender: "Femenina", voiceAge: "Adulta", patientId: patientId) + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Extensions/UserManagementExtensions.swift b/nuevoamanecer/Admin/UserManagement/Extensions/UserManagementExtensions.swift new file mode 100644 index 0000000..7a280b8 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Extensions/UserManagementExtensions.swift @@ -0,0 +1,168 @@ +// +// UserManagementExtensions.swift +// nuevoamanecer +// +// Created by emilio on 10/10/23. +// + +import Foundation +import SwiftUI + +extension UserManagement { + func addUser(userToAdd: User, withImage: UIImage?, withPassword: String, runAtSuccessfulAddition: (()->Void)?) -> Void { + executeWithPasswordConfirmation = { currUserPassword in + let addUserToFirestore: (User)->Void = { user in + self.userVM.addUserWithCustomId(user: user, userId: user.id!) { error in + if error != nil{ + // Error al añadir usuario. + self.showError(errorMessage: "La creación del usuario no fue exitosa") + } else { + self.users.append(user) + self.performUserFiltering() + self.userBeingEdited = nil + self.creatingUser = false + + if runAtSuccessfulAddition != nil { + runAtSuccessfulAddition!() + } + } + } + } + + Task { + if withImage != nil { + if let userWithImage: User = await self.addImageToUser(user: userToAdd, image: withImage!) { + if let userWithAuthId: User = await self.addUserToAuth(user: userWithImage, withPassword: withPassword, currUserPassword: currUserPassword) { + addUserToFirestore(userWithAuthId) + } else { + self.showError(errorMessage: "La creación del usuario no fue exitosa") + } + } else { + self.showError(errorMessage: "No fue posible cargar la imagén del usuario") + } + } else { + if let userWithAuthId: User = await self.addUserToAuth(user: userToAdd, withPassword: withPassword, currUserPassword: currUserPassword) { + addUserToFirestore(userWithAuthId) + } else { + self.showError(errorMessage: "La creación del usuario no fue exitosa") + } + } + } + } + } + + func removeUser(userToRemove: User) -> Void { + if userToRemove.id != nil { + self.userBeingRemoved = userToRemove + self.isDeletingUser = true + } + } + + func editUser(userToEdit: User, withImage: UIImage?, removingImage: String?, runAtSuccessfulEdit: (()->Void)?) -> Void { + let editUserFromFirestore: (User)->Void = { newUserValue in + self.userVM.editUser(userId: newUserValue.id!, newUserValue: newUserValue) { error in + if error != nil { + self.showError(errorMessage: "Error al guardar los cambios") + return + } else { + do { + try self.users.replaceItem(with: newUserValue, where: {$0.id == newUserValue.id}) + } catch { + // No fue posible actualizar localmente el usuario. + } + + self.userBeingEdited = nil + + if runAtSuccessfulEdit != nil { + runAtSuccessfulEdit!() + } + } + } + } + + Task { + if withImage != nil { + if let userWithImage: User = await self.replaceUserImage(user: userToEdit, image: withImage!){ + editUserFromFirestore(userWithImage) + } else { + self.showError(errorMessage: "Error al guardar al nueva imagén del usuario") + } + } else { + editUserFromFirestore(userToEdit) + } + + if removingImage != nil { + _ = await self.imageHandler.deleteImage(donwloadUrl: removingImage!) + } + } + } + + func _removeUser(userToRemove: User) -> Void { + let removeUserFromFirestore: (User)->Void = { user in + self.userVM.removeUser(userId: user.id!) { error in + if error != nil { + // Error al eliminar usuario. + self.showError(errorMessage: "Imposible eliminar al usuario") + } else { + self.users = self.users.filter {$0.id != user.id} + self.performUserFiltering() + } + } + } + + if userToRemove.image != nil { + Task { + if let userWithoutImage: User = await self.removeImageFromUser(user: userToRemove) { + removeUserFromFirestore(userWithoutImage) + } else { + self.showError(errorMessage: "Imposible eliminar al usuario") + } + } + } else { + removeUserFromFirestore(userToRemove) + } + } + + // Image operations: + private func addImageToUser(user: User, image: UIImage) async -> User? { + var userWithImage: User = user + if let imageUrl: URL = await self.imageHandler.uploadImage(image: image, name: buildUserImageName(user: userWithImage)) { + userWithImage.image = imageUrl.absoluteString + return userWithImage + } + return nil + } + + private func removeImageFromUser(user: User) async -> User? { + var userWithoutImage: User = user + if await self.imageHandler.deleteImage(donwloadUrl: user.image!) { + userWithoutImage.image = nil + return userWithoutImage + } + return nil + } + + private func replaceUserImage(user: User, image: UIImage) async -> User? { + if user.image != nil { + if let userWithoutImage: User = await self.removeImageFromUser(user: user) { + return await self.addImageToUser(user: userWithoutImage, image: image) + } + return nil + } else { + return await self.addImageToUser(user: user, image: image) + } + } + + // Auth operations: + private func addUserToAuth(user: User, withPassword: String, currUserPassword: String) async -> User? { + var userWithAuthId: User = user + + let userCreationResult: AuthActionResult = await self.authVM.createNewAuthAccount(email: user.email, password: withPassword, currUserPassword: currUserPassword) + + if userCreationResult.success { + userWithAuthId.id = userCreationResult.userId! + return userWithAuthId + } + return nil + } +} diff --git a/nuevoamanecer/Admin/UserManagement/GenericViews/ChangeIndicatorView.swift b/nuevoamanecer/Admin/UserManagement/GenericViews/ChangeIndicatorView.swift new file mode 100644 index 0000000..e482944 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/GenericViews/ChangeIndicatorView.swift @@ -0,0 +1,22 @@ +// +// ChangeIndicatorView.swift +// nuevoamanecer +// +// Created by emilio on 03/10/23. +// + +import SwiftUI + +struct ChangeIndicatorView: View { + var showIndicator: Bool + + var body: some View { + if showIndicator { + Image(systemName: "largecircle.fill.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 10) + .foregroundColor(.blue) + } + } +} diff --git a/nuevoamanecer/Admin/UserManagement/GenericViews/DualChoiceView.swift b/nuevoamanecer/Admin/UserManagement/GenericViews/DualChoiceView.swift new file mode 100644 index 0000000..89abd45 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/GenericViews/DualChoiceView.swift @@ -0,0 +1,53 @@ +// +// DualChoiceView.swift +// nuevoamanecer +// +// Created by emilio on 26/09/23. +// + +import SwiftUI + +struct DualChoiceView: View { + @Binding var choice: Bool + var width: CGFloat = 80 + var height: CGFloat = 30 + var labels: (String, String) + var isBeingEdited: Bool + var isDisabled: Bool + + var body: some View { + ZStack { + Rectangle() + .frame(width: width, height: height) + .foregroundColor(Color(red: 0.9, green: 0.9, blue: 0.9)) + .overlay(alignment: choice ? .leading : .trailing){ + Rectangle() + .frame(width: width/2, height: height) + .foregroundColor(choice ? .green : .red) + } + + HStack(spacing: width/4) { + Button { + withAnimation { + choice = true + } + } label: { + Text(labels.0) + } + + Button { + withAnimation { + choice = false + } + } label: { + Text(labels.1) + } + } + .foregroundColor(.black) + .allowsHitTesting(!isDisabled) + } + .clipShape(RoundedRectangle(cornerRadius: 5)) + //.border(.gray, width: isBeingEdited ? 1 : 0) + } +} + diff --git a/nuevoamanecer/Admin/UserManagement/GenericViews/DualTextFieldView.swift b/nuevoamanecer/Admin/UserManagement/GenericViews/DualTextFieldView.swift new file mode 100644 index 0000000..3e7ee13 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/GenericViews/DualTextFieldView.swift @@ -0,0 +1,27 @@ +// +// DualTextFieldView.swift +// nuevoamanecer +// +// Created by emilio on 26/09/23. +// + +import SwiftUI + +struct DualTextFieldView: View { + @Binding var text: String + var placeholder: String + var editing: Bool + var fontSize: CGFloat + var width: CGFloat = 35 + + var body: some View { + if editing { + TextFieldView(fieldWidth: 350, fieldHeight: width, fontSize: fontSize, placeHolder: placeholder, background: Color(red: 0.8, green: 0.8, blue: 0.8), inputText: $text) + .autocorrectionDisabled(true) + .autocapitalization(.none) + } else { + Text(text) + .font(.system(size: fontSize)) + } + } +} diff --git a/nuevoamanecer/Admin/UserManagement/GenericViews/EditPanelView.swift b/nuevoamanecer/Admin/UserManagement/GenericViews/EditPanelView.swift new file mode 100644 index 0000000..ebf0c3a --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/GenericViews/EditPanelView.swift @@ -0,0 +1,81 @@ +// +// EditPanelView.swift +// nuevoamanecer +// +// Created by emilio on 29/09/23. +// + +import SwiftUI + +struct EditPanelView: View { + var isBeingEdited: Bool + var isNewUser: Bool + var disableSave: Bool + var runAtEdit: ()->Void + var runAtDelete: ()->Void + var runAtSave: ()->Void + var runAtCancel: ()->Void + + var body: some View { + if isBeingEdited { + isBeingEditedPanel + } else { + isNotBeingEditedPanel + } + } + + var isNotBeingEditedPanel: some View { + HStack(spacing: 40){ + //let iconWidth: CGFloat = 25 + + Button { + runAtEdit() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) // Cuadrado con bordes redondeados + .stroke(lineWidth: 2) // Borde del cuadrado + .frame(width: 40, height: 40) // Dimensiones del cuadrado + .background(.blue) + .foregroundColor(.blue) + Image(systemName: "pencil") // Icono de basura + .resizable() // Hacer que el icono sea redimensionable + .scaledToFit() // Escalar el icono para que encaje dentro del cuadrado + .frame(width: 25, height: 25) // Dimensiones del icono + .foregroundColor(.white) + } + } + .cornerRadius(10) + + + Button { + runAtDelete() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) // Cuadrado con bordes redondeados + .stroke(lineWidth: 2) // Borde del cuadrado + .frame(width: 40, height: 40) // Dimensiones del cuadrado + .background(.red) + .foregroundColor(.red) + Image(systemName: "trash") // Icono de basura + .resizable() // Hacer que el icono sea redimensionable + .scaledToFit() // Escalar el icono para que encaje dentro del cuadrado + .frame(width: 25, height: 25) // Dimensiones del icono + .foregroundColor(.white) + } + } + .cornerRadius(10) + } + } + + var isBeingEditedPanel: some View { + VStack(spacing: 10) { + ButtonWithImageView(text: "Guardar", width: 150, systemNameImage: "square.and.arrow.down", isDisabled: disableSave){ + runAtSave() + } + + ButtonWithImageView(text: "Cancelar", width: 150, systemNameImage: "xmark", background: .red){ + runAtCancel() + } + } + } +} diff --git a/nuevoamanecer/Admin/UserManagement/MainViews/UserManagement.swift b/nuevoamanecer/Admin/UserManagement/MainViews/UserManagement.swift new file mode 100644 index 0000000..6b54d6d --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/MainViews/UserManagement.swift @@ -0,0 +1,172 @@ +// +// UserManagement.swift +// nuevoamanecer +// +// Created by emilio on 26/09/23. +// + +import SwiftUI + +struct UserManagement: View { + // Variables de entorno + @EnvironmentObject var authVM: AuthViewModel + + // Variables de la vista + @State var users: [User] = [] + // filteredUsers: arreglo de tuplas. Cada tupla contiene un usuario y el índice del usuario en el arreglo de usuarios 'users'. + @State var filteredUsers: [(Int, User)] = [] + @State var userBeingEdited: String? = nil // user's id. + @State var creatingUser: Bool = false + var userVM: UserViewModel = UserViewModel() + + @State var searchText: String = "" + @State var pickedUserType: UserType = .baseUserOrAdminUser + + @State var isDeletingUser: Bool = false + @State var userBeingRemoved: User? = nil // user's id. + + @State var showErrorMessage: Bool = false + @State var errorMessage: String = "" + + @State var executeWithPasswordConfirmation: ((String) -> Void)? = nil + + let imageHandler: FirebaseAlmacenamiento = FirebaseAlmacenamiento() + + @State var actionInProgress: Bool = true + + var body: some View { + let leadingPadding: CGFloat = 100 + let trailingPadding: CGFloat = 150 + + GeometryReader { geo in + ZStack { + VStack { + HStack(alignment: .center) { + SearchBarView(searchText: $searchText, placeholder: "Buscar usuario", searchBarWidth: 300) + .onChange(of: searchText) { _ in + performUserFiltering() + } + + Spacer() + + ButtonWithImageView(text: "Nuevo Usuario", systemNameImage: "plus.circle.fill", imagePosition: .left, isDisabled: creatingUser) { + creatingUser = true + userBeingEdited = nil + } + } + .padding(.leading, leadingPadding) + .padding(.trailing, trailingPadding) + + HStack { + Text("Filtrado") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color.gray) + .padding(.trailing) + + Divider() + + Picker("Filtro", selection: $pickedUserType) { + ForEach(UserType.allCases) { filter in + Text(filter.rawValue) + } + } + .onChange(of: pickedUserType) { _ in + performUserFiltering() + } + + Spacer() + } + .padding(.leading, leadingPadding) + .padding(.trailing, trailingPadding) + .padding(.vertical, 10) + .frame(height: 70) + + Divider() + + ScrollView { + VStack(spacing: 0) { + if creatingUser { + NewUserView(userBeingEdited: $userBeingEdited, actionInProgress: $actionInProgress) { userOperation, userOperationData in + self.makeUserOperation(userOperation: userOperation, userOperationData: userOperationData) + } + } + + ForEach(filteredUsers, id: \.1.id) { (index, _) in + UserView(user: $users[index], userBeingEdited: $userBeingEdited, actionInProgress: $actionInProgress) { userOperation, userOperationData in + self.makeUserOperation(userOperation: userOperation, userOperationData: userOperationData) + } + } + + if filteredUsers.isEmpty && (!searchText.isEmpty || pickedUserType != .baseUserOrAdminUser) { + Text("Sin resultados") + .font(.system(size: 20, weight: .bold)) + .padding(.vertical, 200) + } + } + } + } + + if actionInProgress { + ProgressView() + .progressViewStyle(.circular) + } + + if executeWithPasswordConfirmation != nil { + PasswordInputWindowView(action: $executeWithPasswordConfirmation) + } + } + } + .onAppear { + userVM.getAllUsers { error, fetchedUsers in + if error != nil || fetchedUsers == nil { + // Error al obtener usuarios + } else { + users = fetchedUsers! + filteredUsers = sortUsersByName(userIndexes: userArrayToIndexesArray(users: users)) + actionInProgress = false + } + } + } + .customConfirmAlert(title: "Eliminar usuario", message: "El usuario será eliminado para siempre", isPresented: $isDeletingUser) { + self._removeUser(userToRemove: userBeingRemoved!) + } + .customAlert(title: "Error", message: errorMessage, isPresented: $showErrorMessage) + } + + func makeUserOperation(userOperation: UserOperation, userOperationData: UserOperationData) -> Void { + switch userOperation { + case .addMe: + self.addUser(userToAdd: userOperationData.userData, withImage: userOperationData.imageToAdd, withPassword: userOperationData.userPassword!, runAtSuccessfulAddition: userOperationData.runAtSuccess) + case .removeMe: + self.removeUser(userToRemove: userOperationData.userData) + case .editMe: + self.editUser(userToEdit: userOperationData.userData, withImage: userOperationData.imageToAdd, removingImage: userOperationData.imageToRemove, runAtSuccessfulEdit: userOperationData.runAtSuccess) + case .cancelMyCreation: + self.creatingUser = false + } + } + + func showError(errorMessage: String) -> Void { + self.errorMessage = errorMessage + self.showErrorMessage = true + } + + func performUserFiltering() -> Void { + self.filteredUsers = filterUsers(userIndexes: sortUsersByName(userIndexes: userArrayToIndexesArray(users: users)), searchText: searchText, userType: pickedUserType) + } +} + +func buildUserImageName(user: User) -> String { + return "User_\(user.name.removeWhitespaces())_\(UUID().uuidString)" +} + +enum UserOperation { + case addMe, removeMe, editMe, cancelMyCreation +} + +enum UserType: String, CaseIterable, Identifiable { + case baseUserOrAdminUser = "Todos" + case baseUser = "No Administradores" + case adminUser = "Administradores" + var id: Self { self } +} diff --git a/nuevoamanecer/Admin/UserManagement/MiscMethods/UserMethods.swift b/nuevoamanecer/Admin/UserManagement/MiscMethods/UserMethods.swift new file mode 100644 index 0000000..53156ef --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/MiscMethods/UserMethods.swift @@ -0,0 +1,44 @@ +// +// UserMethods.swift +// nuevoamanecer +// +// Created by emilio on 08/10/23. +// + +import Foundation +import SwiftUI + +func filterUsers(userIndexes: [(Int, User)], searchText: String, userType: UserType = .baseUserOrAdminUser) -> [(Int, User)] { + if !searchText.isEmpty { + return filterUsersByType(userIndexes: filterUsersBySearchText(userIndexes: userIndexes, searchText: searchText), userType: userType) + } else { + return filterUsersByType(userIndexes: userIndexes, userType: userType) + } +} + +func filterUsersByType(userIndexes: [(Int, User)], userType: UserType) -> [(Int, User)] { + return userIndexes.filter { (index, user) in + switch userType { + case .baseUserOrAdminUser: + return true + case .adminUser: + return user.isAdmin == true + case .baseUser: + return user.isAdmin == false + } + } +} + +func filterUsersBySearchText(userIndexes: [(Int, User)], searchText: String) -> [(Int, User)] { + let cleanedSearchText: String = searchText.cleanForSearch() + return userIndexes.filter {(index, user) in user.name.cleanForSearch().contains(cleanedSearchText)} +} + +func sortUsersByName(userIndexes: [(Int, User)]) -> [(Int, User)] { + return userIndexes.sorted {$0.1.name.cleanForSearch() < $1.1.name.cleanForSearch()} +} + +func userArrayToIndexesArray(users: [User]) -> [(Int, User)] { + return users.enumerated().map {(index, user) in (index, user)} +} + diff --git a/nuevoamanecer/Admin/UserManagement/MiscStructures/PasswordValidator.swift b/nuevoamanecer/Admin/UserManagement/MiscStructures/PasswordValidator.swift new file mode 100644 index 0000000..ea7ade9 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/MiscStructures/PasswordValidator.swift @@ -0,0 +1,57 @@ +// +// PasswordValidityState.swift +// nuevoamanecer +// +// Created by emilio on 20/10/23. +// + +import Foundation + +// Reglas que contraseñas de usuario deben obedecer: +// Contiene por lo menos 8 caracteres. +// Contiene por lo menos una mayúscula. +// Contiene por lo menos una minúscula. +// Contiene por lo menos un número. +// Contains only numbers and alphabetical characters. + +struct PasswordValidator { + let password: String + + init(_ password: String) { + self.password = password + } + + func isValidPassword() -> Bool { + return self.password.contains(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,}$/) + } + + func hasValidLength() -> Bool { + return self.password.count > 7 + } + + func hasAnUppercaseLetter() -> Bool { + return self.password.contains(/^.*[A-Z].*$/) + } + + func hasALowercaseLetter() -> Bool { + return self.password.contains(/^.*[a-z].*$/) + } + + func hasANumber() -> Bool { + return self.password.contains(/^.*\d.*$/) + } + + func containsOnlyLettersAndNumbers() -> Bool { + return self.password.contains(/^[a-zA-Z0-9]+$/) + } + + func buildValidationList() -> [String:Bool] { + return [ + "Contiene más de 8 caracteres": self.hasValidLength(), + "Contiene una letra mayúscula": self.hasAnUppercaseLetter(), + "Contiene una letra minúscula": self.hasALowercaseLetter(), + "Contiene un número": self.hasANumber(), + "Contiene únicamente números y letras": self.containsOnlyLettersAndNumbers() + ] + } +} diff --git a/nuevoamanecer/Admin/UserManagement/MiscStructures/UserOperationData.swift b/nuevoamanecer/Admin/UserManagement/MiscStructures/UserOperationData.swift new file mode 100644 index 0000000..8601b65 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/MiscStructures/UserOperationData.swift @@ -0,0 +1,25 @@ +// +// UserOperationDaat.swift +// nuevoamanecer +// +// Created by emilio on 17/10/23. +// + +import Foundation +import SwiftUI + +struct UserOperationData { + let userData: User + let imageToAdd: UIImage? + let imageToRemove: String? + let userPassword: String? + let runAtSuccess: (()->Void)? + + init(userData: User, imageToAdd: UIImage? = nil, imageToRemove: String? = nil, userPassword: String? = nil, runAtSuccess: (()->Void)? = nil){ + self.userData = userData + self.imageToAdd = imageToAdd + self.imageToRemove = imageToRemove + self.userPassword = userPassword + self.runAtSuccess = runAtSuccess + } +} diff --git a/nuevoamanecer/Admin/UserManagement/ViewModels/UserViewModel.swift b/nuevoamanecer/Admin/UserManagement/ViewModels/UserViewModel.swift new file mode 100644 index 0000000..f5e6c1d --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/ViewModels/UserViewModel.swift @@ -0,0 +1,119 @@ +// +// UserViewModel.swift +// nuevoamanecer +// +// Created by emilio on 22/09/23. +// + +import Foundation +import Firebase +import FirebaseFirestore + +enum UserProperty: String { + case name = "name" + case email = "email" + case image = "image" +} + +class UserViewModel { + private var userCollection: CollectionReference = Firestore.firestore().collection("User") + + func getUser(userId: String, completition: @escaping (Error?, User?)->Void) -> Void { + self.userCollection.document(userId).getDocument(as: User.self) { result in + do { + completition(nil, try result.get()) + } catch let error { + completition(error, nil) + } + } + } + + func getAllUsers(completition: @escaping (Error?, [User]?)->Void) -> Void { + userCollection.getDocuments { querySnapshot, error in + if error != nil { + completition(error, nil) + } else { + var users: [User] = [] + + for document in querySnapshot!.documents { + do { + users.append(try document.data(as: User.self)) + } catch let error { + completition(error, nil) + } + } + completition(nil, users) + } + } + } + + func addUser(user: User, completition: @escaping (Error?, String?)->Void) -> Void { + var docRef: DocumentReference? = nil + + do { + docRef = try userCollection.addDocument(from: user) { error in + if error != nil { + completition(error, nil) + } else { + completition(nil, docRef?.documentID) + } + } + } catch let error { + completition(error, nil) + } + } + + func addUserWithCustomId(user: User, userId: String, completition: @escaping (Error?)->Void) -> Void { + userCollection.document(userId).setData(user.toDict()) {error in + if error != nil { + completition(error) + } else { + completition(nil) + } + } + } + + func removeUser(userId: String, completittion: @escaping (Error?)->Void) -> Void { + userCollection.document(userId).delete { error in + if error != nil { + completittion(error) + } else { + completittion(nil) + } + } + } + + func editUser(userId: String, newUserValue: User, completition: @escaping (Error?)->Void) -> Void { + do { + try userCollection.document(userId).setData(from: newUserValue) { error in + if error != nil { + completition(error) + } else { + completition(nil) + } + } + } catch let error { + completition(error) + } + } + + func editUserProperty(userId: String, value: String, userProperty: UserProperty, completition: @escaping (Error?)->Void) -> Void { + userCollection.document(userId).updateData([userProperty.rawValue: value]) { error in + if error != nil { + completition(error) + } else { + completition(nil) + } + } + } + + func editUserAdminState(userId: String, isAdmin: Bool, completition: @escaping (Error?)->Void) -> Void { + userCollection.document(userId).updateData(["isAdmin": isAdmin]) { error in + if error != nil { + completition(error) + } else { + completition(nil) + } + } + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Views/NewUserView.swift b/nuevoamanecer/Admin/UserManagement/Views/NewUserView.swift new file mode 100644 index 0000000..4906432 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Views/NewUserView.swift @@ -0,0 +1,19 @@ +// +// NewUserView.swift +// nuevoamanecer +// +// Created by emilio on 18/10/23. +// + +import SwiftUI + +struct NewUserView: View { + @State var newUser: User = User.newEmptyUser() + @Binding var userBeingEdited: String? + @Binding var actionInProgress: Bool + var makeUserOperation: (UserOperation, UserOperationData) -> Void + + var body: some View { + UserView(user: $newUser, userBeingEdited: $userBeingEdited, actionInProgress: $actionInProgress, makeUserOperation: makeUserOperation) + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Views/PasswordInputWindowView.swift b/nuevoamanecer/Admin/UserManagement/Views/PasswordInputWindowView.swift new file mode 100644 index 0000000..7c08d9c --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Views/PasswordInputWindowView.swift @@ -0,0 +1,49 @@ +// +// UserPasswordInputView.swift +// nuevoamanecer +// +// Created by emilio on 08/10/23. +// + +import SwiftUI + +struct PasswordInputWindowView: View { + @EnvironmentObject var currentUser: UserWrapper + + @Binding var action: ((String)->Void)? + @State private var password: String = "" + let width: CGFloat = 400 + let height: CGFloat = 200 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .foregroundColor(.black) + .frame(width: width + 1, height: height + 1) + + + VStack(spacing: 20) { + let currentUserName: String = currentUser.name != nil ? " (\(currentUser.name!))" : "" + Text("Ingrese su contraseña" + currentUserName) + .bold() + + PasswordInputTextFieldView(password: $password) + + HStack (spacing: 10) { + ButtonView(text: "Cancelar", color: .gray) { + action = nil + } + + ButtonView(text: "Confirmar", color: .blue, isDisabled: action == nil || password.count < 3) { + action!(password) + action = nil + } + } + } + .padding() + .frame(width: width, height: height) + .background(.white) + .cornerRadius(10) + } + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Views/UserImageEditView.swift b/nuevoamanecer/Admin/UserManagement/Views/UserImageEditView.swift new file mode 100644 index 0000000..ab7c033 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Views/UserImageEditView.swift @@ -0,0 +1,92 @@ +// +// UserImageView.swift +// nuevoamanecer +// +// Created by emilio on 09/10/23. +// + +import SwiftUI +import Kingfisher + +struct UserImageEditView: View { + @Binding var user: User + @Binding var userSnaphot: User + @Binding var pickedUserImage: UIImage? + let isBeingEdited: Bool + + @State var showImageEditMenu: Bool = false + @State var showImagePicker: Bool = false + + var body: some View { + Menu(content: {imageEditMenu}, label: {userImageDisplay}) + .fullScreenCover(isPresented: $showImagePicker){ + ImagePicker(image: $pickedUserImage) + } + .allowsHitTesting(isBeingEdited) + } + + var userImageDisplay: some View { + VStack { + if pickedUserImage != nil { + Image(uiImage: pickedUserImage!) + .resizable() + .modifier(UserImageStyle()) + } else { + KFImage(URL(string: user.image ?? "")) + .placeholder { + let nameComponents: [String] = self.user.name.splitAtWhitespaces() + ImagePlaceholderView(firstName: nameComponents.getElementSafely(index: 0) ?? "", + lastName: nameComponents.getElementSafely(index: 1) ?? "") + } + .resizable() + .modifier(UserImageStyle()) + } + } + .overlay(alignment: .bottomTrailing) { + if isBeingEdited { + Image(systemName: "photo.on.rectangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30) + .foregroundColor(.black) + } + } + .overlay(alignment: .leading) { + if isBeingEdited { + ChangeIndicatorView(showIndicator: pickedUserImage != nil || user.image != userSnaphot.image) + .offset(x: -20) + } + } + } + + var imageEditMenu: some View { + Section { + if user.image != nil || pickedUserImage != nil { + Button { + if pickedUserImage != nil { + pickedUserImage = nil + } else { + user.image = nil + } + } label: { + Label(pickedUserImage != nil ? "Cancelar selección" : "Eliminar imagén" , systemImage: "xmark") + } + } + + Button { + showImagePicker = true + } label: { + Label("Seleccionar una imagén", systemImage: "square.and.arrow.up") + } + } + } +} + +struct UserImageStyle: ViewModifier { + func body(content: Content) -> some View { + content + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Views/UserView.swift b/nuevoamanecer/Admin/UserManagement/Views/UserView.swift new file mode 100644 index 0000000..3424f3e --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Views/UserView.swift @@ -0,0 +1,153 @@ +// +// UserView.swift +// nuevoamanecer +// +// Created by emilio on 26/09/23. +// + +import SwiftUI + +struct UserView: View { + @EnvironmentObject var currentUser: UserWrapper + + @Binding var user: User + @State var userPassword: String = "" + @Binding var userBeingEdited: String? + @Binding var actionInProgress: Bool + @State var userSnapshot: User + var makeUserOperation: (UserOperation, UserOperationData) -> Void + + @State var pickedUserImage: UIImage? = nil + + var isNewUser: Bool {self.user.id == nil} + var isBeingEdited: Bool {self.user.id == self.userBeingEdited} + + @State var showingPasswordValidationList: Bool = false + + init(user: Binding, userBeingEdited: Binding, actionInProgress: Binding, makeUserOperation: @escaping (UserOperation, UserOperationData) -> Void){ + self._user = user + self._userSnapshot = State(initialValue: user.wrappedValue) + self._userBeingEdited = userBeingEdited + self._actionInProgress = actionInProgress + self.makeUserOperation = makeUserOperation + } + + var body: some View { + let leadingPadding: CGFloat = 100 + let trailingPadding: CGFloat = 150 + + VStack { + HStack(alignment: .center) { + HStack(spacing: 35) { + VStack { + Text(isNewUser ? "Usuario Nuevo" : (currentUser.id == user.id ? "Usuario Actual" : "")) + .font(.system(size: 15)) + .foregroundColor(.gray) + .opacity(isNewUser || currentUser.id == user.id ? 1 : 0) + + UserImageEditView(user: $user, userSnaphot: $userSnapshot, pickedUserImage: $pickedUserImage, isBeingEdited: isBeingEdited || isNewUser) + } + + VStack(alignment: .leading, spacing: 15) { + HStack(alignment: .center, spacing: 10) { + DualTextFieldView(text: $user.name, placeholder: "Nombre", editing: isNewUser || isBeingEdited, fontSize: 20) + .bold() + ChangeIndicatorView(showIndicator: user.name != userSnapshot.name && !isNewUser) + InvalidInputView(show: !user.name.isEmpty && !user.hasValidName(), text: "Nombre inválido") + } + + HStack(alignment: .center, spacing: 10) { + DualTextFieldView(text: $user.email, placeholder: "Correo", editing: isNewUser, fontSize: 15) + ChangeIndicatorView(showIndicator: user.email != userSnapshot.email && !isNewUser) + InvalidInputView(show: !user.email.isEmpty && !user.hasValidEmail(), text: "Correo inválido") + } + + if isNewUser { + HStack(alignment: .center, spacing: 10) { + DualTextFieldView(text: $userPassword, placeholder: "Contraseña", editing: isNewUser, fontSize: 15) + .onChange(of: userPassword) { _ in + self.showingPasswordValidationList = true + } + .popover(isPresented: $showingPasswordValidationList, arrowEdge: .bottom) { + ValidationListDisplayView(validationList: PasswordValidator(userPassword).buildValidationList()) + .padding() + } + InvalidInputView(show: !userPassword.isEmpty && !PasswordValidator(userPassword).isValidPassword(), text: "Contraseña inválida") + } + } + + HStack(spacing: 10) { + Text("Administrador: ") + .font(.system(size: 15)) + DualChoiceView(choice: $user.isAdmin, labels: ("Sí", "No"), isBeingEdited: isNewUser || isBeingEdited, isDisabled: !isBeingEdited) + ChangeIndicatorView(showIndicator: user.isAdmin != userSnapshot.isAdmin && !isNewUser) + } + } + } + + Spacer() + + let runAtCancel: ()->Void = { + if isNewUser { + makeUserOperation(.cancelMyCreation, UserOperationData(userData: user)) + } else { + userBeingEdited = nil + } + + self.pickedUserImage = nil + self.user = self.userSnapshot + } + + let runAtSave: ()->Void = { + actionInProgress = true + + if isNewUser { + makeUserOperation(.addMe, UserOperationData(userData: user, imageToAdd: pickedUserImage, userPassword: userPassword, runAtSuccess: {actionInProgress = false})) + } else { + let runAtSuccess: ()->Void = { + pickedUserImage = nil + userSnapshot = user + actionInProgress = false + } + + makeUserOperation(.editMe, UserOperationData(userData: user, imageToAdd: pickedUserImage, imageToRemove: user.image == nil && userSnapshot.image != nil ? userSnapshot.image : nil, runAtSuccess: runAtSuccess)) + } + } + + if user.id != currentUser.id { + let disableSave: Bool = !user.isValidUser() || (user == userSnapshot && pickedUserImage == nil) || (isNewUser && !PasswordValidator(userPassword).isValidPassword()) + + EditPanelView(isBeingEdited: isBeingEdited || isNewUser, + isNewUser: isNewUser, + disableSave: disableSave, + runAtEdit: {self.userBeingEdited = self.user.id}, + runAtDelete: {makeUserOperation(.removeMe, UserOperationData(userData: user))}, + runAtSave: runAtSave, + runAtCancel: runAtCancel) + } + } + .padding(.leading, leadingPadding) + .padding(.trailing, trailingPadding) + .padding(.vertical, 20) + + Divider() + } + .background(isBeingEdited || isNewUser ? Color(red: 0.95, green: 0.95, blue: 0.95) : .white) + .onChange(of: userBeingEdited) { _ in + if user.id != userBeingEdited && userBeingEdited != nil { + user = userSnapshot + } + } + } +} + +struct InvalidInputView: View { + let show: Bool + let text: String + + var body: some View { + Text(text) + .foregroundColor(.gray) + .opacity(show ? 1 : 0) + } +} diff --git a/nuevoamanecer/Admin/UserManagement/Views/ValidationListDisplayView.swift b/nuevoamanecer/Admin/UserManagement/Views/ValidationListDisplayView.swift new file mode 100644 index 0000000..0bc4f58 --- /dev/null +++ b/nuevoamanecer/Admin/UserManagement/Views/ValidationListDisplayView.swift @@ -0,0 +1,26 @@ +// +// PasswordValidationVieew.swift +// nuevoamanecer +// +// Created by emilio on 20/10/23. +// + +import SwiftUI + +struct ValidationListDisplayView: View { + let validationList: [String:Bool] // [Regla de validación:estado del cumplimiento de la regla] + + var body: some View { + VStack(alignment: .leading) { + ForEach(Array(validationList.keys.sorted {$0 < $1}), id: \.self){ key in + HStack{ + Image(systemName: validationList[key]! ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(validationList[key]! ? .green : .red) + Text(key) + + } + Divider() + } + } + } +} diff --git a/nuevoamanecer/Admin/View/Admin/AdminDashboard/AddPatientView.swift b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AddPatientView.swift new file mode 100644 index 0000000..f5f2377 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AddPatientView.swift @@ -0,0 +1,347 @@ +// +// AddPatientView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 22/05/23. +// + +import SwiftUI +import FirebaseStorage + + +struct AddPatientView: View { + @Environment(\.dismiss) var dismiss + @ObservedObject var patients: PatientsViewModel + + // Variables para los selectores de nivel cognitivo y estilo de comunicación + let patientId = UUID().uuidString + var cognitiveLevels = ["Alto", "Medio", "Bajo"] + @State private var congnitiveLevelSelector = "" + + var communicationStyles = ["Verbal", "No-verbal", "Mixto"] + @State private var communicationStyleSelector = "" + + // Variables para los campos de texto del formulario + @State private var firstName: String = "" + @State private var lastName: String = "" + @State private var birthDate: Date = Date() + @State private var group: String = "" + @State private var upload_image: UIImage? + + // Variables para mostrar alertas de error + @State private var showAlert = false + @State private var errorTitle: String = "" + @State private var errorMessage: String = "" + @State private var hasSelectedBirthday: Bool = false + + // Variables para la carga de imágenes en Firebase + @State private var storage = FirebaseAlmacenamiento() + @State private var shouldShowImagePicker = false + @State private var imageURL = URL(string: "") + @State private var uploadPatient: Bool = false + @State var isSaving: Bool = false + @State private var deletedImage: Bool = false + @State private var showAlertNoImage: Bool = false + + // Función para cargar la imagen del paciente desde Firebase + func loadImageFromFirebase(name: String) { + let storageRef = Storage.storage().reference(withPath: name) + + storageRef.downloadURL { (url, error) in + if error != nil { + print((error?.localizedDescription)!) + return + } + self.imageURL = url! + } + } + + var body: some View { + + VStack{ + + //Form + VStack{ + Form { + Section(header: Text("Foto del paciente")) { + HStack{ + Spacer() + Menu { + Button(action: { + shouldShowImagePicker.toggle() + deletedImage = false + + }, label: { + Text("Seleccionar Imagen") + }) + Button(action: { + if (upload_image == nil || deletedImage) { + showAlertNoImage.toggle() + } else { + deletedImage = true + upload_image = nil + } + }, label: { + Text("Eliminar Imagen") + }) + } label: { + //Imagen recien cargada + if let displayImage = self.upload_image { + + ZStack{ + Image(uiImage: displayImage) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + //.foregroundColor(.white) + } + .padding(.horizontal, 20) + } else { + + //No imagen + if(upload_image == nil || deletedImage) { + ZStack { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 128)) + .foregroundColor(Color(.systemGray2)) + + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 45, y: 50) + } + .padding(.horizontal, 20) + } + + //Imagen previamente subida + else{ + ZStack{ + Image(uiImage: upload_image!) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + .foregroundColor(.blue) + } + .padding(.horizontal, 20) + } + } + } + Spacer() + } + } + Section(header: Text("Información del Paciente")) { + TextField("Primer Nombre", text: $firstName) + TextField("Apellidos", text: $lastName) + TextField("Grupo", text: $group) + DatePicker("Fecha de nacimiento", selection: $birthDate, displayedComponents: .date) + .onChange(of: birthDate, perform: { value in + hasSelectedBirthday = true + }) + } + + Section(header: Text("Nivel Cognitivo")) { + Picker("Nivel Cognitivo", selection: $congnitiveLevelSelector) { + Text("") + ForEach(cognitiveLevels, id: \.self) { + Text($0) + } + } + } + + Section(header: Text("Estilo de Comunicación")) { + Picker("Tipo de comunicación", selection: $communicationStyleSelector) { + Text("") + ForEach(communicationStyles, id: \.self) { + Text($0) + } + } + } + } + } + + //Buttons + + //VStack(alignment: .leading, spacing: 10) { + HStack{ + + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + + //botón de crear usuario + Button(action: { + + //Subir imagen a firebase + if let thisImage = self.upload_image { + Task { + await storage.uploadImage(image: thisImage, name: "Fotos_perfil/" + patientId + "profile_picture") { url in + + imageURL = url + + //Validamos que no existan campos vaciós + if(firstName == "" || lastName == "" || group == "" || communicationStyleSelector == "" || congnitiveLevelSelector == ""){ + errorTitle = "Campos vacíos" + errorMessage = "Todos los campos deben ser llenados." + showAlert = true + } + //Validamos que selecciono una fecha de nacimiento + else if(!hasSelectedBirthday){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Debes seleccionar una fecha de cumpleaños válida." + showAlert = true + } + //Validamos que no existán caracteres especiales en nombre + else if(!isValidName(name: firstName) || !isValidName(name: lastName)){ + errorTitle = "Favor de volver a ingresar sus datos" + errorMessage = "Nombre y apellido del paciente deben contener solamente letras y no tener espacios en blanco al inicio." + firstName = "" + lastName = "" + showAlert = true + } + //Validamos que el grupo tenga un nombre valido. solo letras y numeros + else if(!isValidOnlyCharAndNumbers(input: group)){ + errorTitle = "Nombre de grupo inválido" + errorMessage = "Nombre del grupo debe contener solamente letras, números y no tener espacios en blanco al inicio." + group = "" + showAlert = true + } + //Validamos que la fecha de nacimiento no sea en el futuro o en el mes previo + //La razón del mes previo es para promover que escriban la fecha de nacimiento correcta + else if(!isValidBirthDate(birthDate: birthDate)){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Ingresa una fecha de nacimiento previa a un mes." + showAlert = true + } + //Actualización de datos en la base de datos + else { + isSaving = true + uploadPatient.toggle() + dismiss() + } + } + } + } else { + + //Validamos que no existan campos vaciós + if(firstName == "" || lastName == "" || group == "" || communicationStyleSelector == "" || congnitiveLevelSelector == ""){ + errorTitle = "Campos vacíos" + errorMessage = "Todos los campos deben ser llenados." + showAlert = true + } + //Validamos que selecciono una fecha de nacimiento + else if(!hasSelectedBirthday){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Debes seleccionar una fecha de cumpleaños válida." + showAlert = true + } + //Validamos que no existán caracteres especiales en nombre + else if(!isValidName(name: firstName) || !isValidName(name: lastName)){ + errorTitle = "Favor de volver a ingresar sus datos" + errorMessage = "Nombre y apellido del paciente deben contener solamente letras y no tener espacios en blanco al inicio." + firstName = "" + lastName = "" + showAlert = true + } + //Validamos que el grupo tenga un nombre valido. solo letras y numeros + else if(!isValidOnlyCharAndNumbers(input: group)){ + errorTitle = "Nombre de grupo inválido" + errorMessage = "Nombre del grupo debe contener solamente letras, números y no tener espacios en blanco al inicio." + group = "" + showAlert = true + } + //Validamos que la fecha de nacimiento no sea en el futuro o en el mes previo + //La razón del mes previo es para promover que escriban la fecha de nacimiento correcta + else if(!isValidBirthDate(birthDate: birthDate)){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Ingresa una fecha de nacimiento previa a un mes." + showAlert = true + } + //Actualización de datos en la base de datos + else { + isSaving = true + uploadPatient.toggle() + dismiss() + } + } + }){ + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .cornerRadius(10) + .foregroundColor(.white) + .allowsHitTesting(!isSaving) + .alert(errorTitle, isPresented: $showAlert){ + Button("Ok") {} + } + message: { + Text(errorMessage) + } + } + } + .padding() + .background(Color(.init(white: 0, alpha: 0.05)) + .ignoresSafeArea()) + .fullScreenCover(isPresented: $shouldShowImagePicker, onDismiss: nil) { + ImagePicker(image: $upload_image) + + } + .onDisappear { + if(uploadPatient) { + let patient = Patient(id: patientId ,firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyleSelector, cognitiveLevel: congnitiveLevelSelector, image: imageURL?.absoluteString ?? "placeholder", notes: [String](), identificador: patientId) + + patients.addData(patient: patient){ error in + if error != "OK" { + print(error) + }else{ + Task { + if let patientsList = await patients.getData(){ + DispatchQueue.main.async { + self.patients.patientsList = patientsList + } + } + } + } + } + } + } + } +} + +struct AddPatientView_Previews: PreviewProvider { + static var previews: some View { + AddPatientView(patients: PatientsViewModel()) + } +} + diff --git a/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminMenuView.swift b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminMenuView.swift new file mode 100644 index 0000000..1d498de --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminMenuView.swift @@ -0,0 +1,386 @@ +// +// AdminMenuView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 06/06/23. +// + +import SwiftUI +import Kingfisher +import FirebaseStorage + +struct AdminMenuView: View { + struct AlertItem: Identifiable { + var id = UUID() + var title: Text + var message: Text? + var dismissButton: Alert.Button? + } + + @Environment(\.dismiss) var dismiss + @EnvironmentObject var authVM: AuthViewModel + var userVM: UserViewModel = UserViewModel() + var user: User + + @State private var name: String + @State private var email: String + @State private var oldEmail: String + @State private var password = "" + @State var authPassword = "" + @State private var confirmpassword = "" + + @State private var showAuthAlert = false + @State private var alertItem: AlertItem? + + @State private var showLogoutAlert = false + + @State private var uploaded_image: UIImage? + @State private var shouldShowImagePicker: Bool = false + @State private var uploadData: Bool = false + @State private var imageURL = URL(string: "") + @State private var storage = FirebaseAlmacenamiento() + + @State private var deletedImage = false + @State private var showPassword: Bool = false + @State private var showConfirmPassword: Bool = false + @State private var emailConfirm: String = "" + @State private var emailValidation: String = "" + @FocusState private var inFocus: Field? + @FocusState private var inFocusConfirm: Field? + + enum Field : Hashable { + case plain + case secure + } + + init(user: User) { + self.user = user + _name = State(initialValue: user.name) + _email = State(initialValue: user.email) + _oldEmail = State(initialValue: user.email) + } + + var body: some View { + VStack { + VStack { + Form { + //Imagen del usuario + Section(header: Text("Usuario")) { + HStack{ + Spacer() + Menu { + Button(action: { + shouldShowImagePicker.toggle() + deletedImage = false + + }, label: { + Text("Seleccionar Imagen") + }) + Button(action: { + if (user.image == "placeholder" || deletedImage) { + self.alertItem = AlertItem(title: Text("Error"), message: Text("Este perfil no cuenta con imagen."), dismissButton: .cancel(Text("OK"))) + } else { + deletedImage = true + } + }, label: { + Text("Eliminar Imagen") + }) + } label: { + + //Imagen recien cargada + if let displayImage = self.uploaded_image { + + ZStack{ + Image(uiImage: displayImage) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + //.foregroundColor(.white) + } + .padding(.horizontal, 20) + } else { + + //No imagen + if(user.image == nil || deletedImage) { + + ZStack{ + Text(user.name.prefix(1) + user.name.prefix(1)) + .textCase(.uppercase) + .font(.title) + .fontWeight(.bold) + .frame(width: 128, height: 128) + .background(Color(.systemGray3)) + .foregroundColor(.white) + .clipShape(Circle()) + .padding(.trailing) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + //.foregroundColor(.white) + } + .padding(.horizontal, 20) + } + + //Imagen previamente subida + else{ + + ZStack{ + KFImage(URL(string: user.image!)) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + .foregroundColor(.blue) + } + .padding(.horizontal, 20) + } + } + } + Spacer() + } + .alert(item: $alertItem ) { alertItem in + Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton) + } + VStack{ + HStack(alignment: .center){ + Spacer() + Text(name) + .font(.title) + .bold() + Spacer() + + } + Text(user.email) + .font(.subheadline) + .foregroundColor(.gray) + } + } + + Section(header: Text("Información")) { + TextField("Nombre", text: $name) + .textContentType(.username) + .autocapitalization(.none) + .autocorrectionDisabled(true) + if (email == oldEmail) { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .autocorrectionDisabled(true) + .autocapitalization(.none) + } else { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .autocorrectionDisabled(true) + .autocapitalization(.none) + TextField("Confirmar Email", text: $emailValidation) + .textContentType(.emailAddress) + .autocorrectionDisabled(true) + .autocapitalization(.none) + } + + ZStack (alignment: .trailing) { + if showPassword { + TextField("Nueva contraseña", text: $password) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .textContentType(.newPassword) + .focused($inFocus, equals: .plain) + } else { + SecureField("Nueva contraseña", text: $password) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .textContentType(.newPassword) + .focused($inFocus, equals: .secure) + } + Button() { + showPassword.toggle() + inFocus = showPassword ? .plain : .secure + } label: { + Image(systemName: showPassword ? "eye" : "eye.slash") + } + } + // + if (password != ""){ + ZStack (alignment: .trailing) { + if showConfirmPassword { + TextField("Confirmar contraseña", text: $confirmpassword) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .textContentType(.password) + .focused($inFocusConfirm, equals: .plain) + } else { + SecureField("Confirmar contraseña", text: $confirmpassword) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .textContentType(.password) + .focused($inFocusConfirm, equals: .secure) + } + Button() { + showConfirmPassword.toggle() + inFocusConfirm = showConfirmPassword ? .plain : .secure + } label: { + Image(systemName: showConfirmPassword ? "eye" : "eye.slash") + //.padding(.bottom) + //.padding(.trailing) + } + } + } + // + } + } + + } + .frame(minHeight: 150) + .padding() + VStack(alignment: .leading, spacing: 10) { + + HStack{ + //Cancelar + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + //Guardar + Button(action: { + + if self.uploaded_image != nil { + Task { + await storage.uploadImage(image: self.uploaded_image!, name: "Fotos_perfil/" + self.user.id! + "admin_profile_picture") { url in + + imageURL = url + + //Checar que datos son validos + if (name.isEmpty || email.isEmpty) { + self.alertItem = AlertItem(title: Text("Faltan campos"), message: Text("Por favor, rellena todos los campos antes de guardar la información."), dismissButton: .cancel(Text("OK"))) + } else if (!email.isValidEmail()){ + self.alertItem = AlertItem(title: Text("Correo inválido"), message: Text("Verifique que su correo sea correcto."), dismissButton: .cancel(Text("OK"))) + } else if (password != confirmpassword) { + self.alertItem = AlertItem(title: Text("Confirme su contraseña"), message: Text("Por favor, verifique correctmente su contraseña."), dismissButton: .cancel(Text("OK"))) + } else if (!PasswordValidator(password).isValidPassword() && !password.isEmpty){ + self.alertItem = AlertItem(title: Text("Contraseña inválida"), message: Text("La contraseña debe de contener 8 caracteres, con mínimo un numero, una mayúscula y un carácter especial."), dismissButton: .cancel(Text("OK"))) + } else if (email == emailValidation || emailValidation.isEmpty) { + showAuthAlert = true + } else { + self.alertItem = AlertItem(title: Text("Correo electrónico no coincide"), message: Text("Los dos correos electrónicos ingresados no coinciden."), dismissButton: .cancel(Text("OK"))) + } + } + } + } else { + //Checar que datos son validos + if (name.isEmpty || email.isEmpty) { + self.alertItem = AlertItem(title: Text("Faltan campos"), message: Text("Por favor, rellena todos los campos antes de guardar la información."), dismissButton: .cancel(Text("OK"))) + } else if (!email.isValidEmail()){ + self.alertItem = AlertItem(title: Text("Correo inválido"), message: Text("Verifique que su correo sea correcto."), dismissButton: .cancel(Text("OK"))) + } else if (password != confirmpassword) { + self.alertItem = AlertItem(title: Text("Confirme su contraseña"), message: Text("Por favor, verifique correctmente su contraseña"), dismissButton: .cancel(Text("OK"))) + } else if (!PasswordValidator(password).isValidPassword() && !password.isEmpty){ + self.alertItem = AlertItem(title: Text("Contraseña inválida"), message: Text("La contraseña debe de contener 8 caracteres, con mínimo un numero, una mayúscula y un carácter especial."), dismissButton: .cancel(Text("OK"))) + } else if (email == emailValidation || emailValidation.isEmpty) { + showAuthAlert = true + } else { + self.alertItem = AlertItem(title: Text("Correo electrónico no coincide"), message: Text("Los dos correos electrónicos ingresados no coinciden."), dismissButton: .cancel(Text("OK"))) + } + } + }) { + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .cornerRadius(10) + .foregroundColor(.white) + } + } + .padding() + Spacer() + } + .padding() + .background(Color(.init(white: 0, alpha: 0.05)) + .ignoresSafeArea()) + .fullScreenCover(isPresented: $shouldShowImagePicker, onDismiss: nil) { + ImagePicker(image: $uploaded_image) + } + .alert(item: $alertItem ) { alertItem in + Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton) + } + .alert("Escribe tu contraseña", isPresented: $showAuthAlert, actions: { + TextField("Contraseña", text: $authPassword) + .autocorrectionDisabled(true) + + Button("Okay", action: { + Task { + let result: AuthActionResult = await authVM.loginAuthUser(email: user.email, password: authPassword) + + if result.success { + uploadData.toggle() + dismiss() + } else { + self.alertItem = AlertItem(title: Text("Contraseña inválida"), message: Text("La contraseña es incorrecta."), dismissButton: .cancel(Text("OK"))) + } + } + }) + Button("Cancel", role: .cancel, action: { }) + }) + .onDisappear{ + if (uploadData) { + self.name = name + self.email = email + + let _user: User = User(name: name, email: email, isAdmin: user.isAdmin, image: imageURL?.absoluteString) + userVM.editUser(userId: user.id!, newUserValue: _user) { error in + if error != nil { + // Error + } else { + Task { + await authVM.updateCurrentAuthUser(value: email, userProperty: .email, currUserPassword: authPassword) + } + } + } + + if !password.isEmpty { + Task { + let result: AuthActionResult = await authVM.updateCurrentAuthUser(value: password, userProperty: .password, currUserPassword: authPassword) + + if result.success { + // Exito + } else { + // Error + } + } + } + } + } + } +} diff --git a/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminView.swift b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminView.swift new file mode 100644 index 0000000..146b698 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/AdminDashboard/AdminView.swift @@ -0,0 +1,529 @@ +// +// AdminView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 19/05/23. +// + +import SwiftUI +import Kingfisher + + +struct AdminView: View { + //Modelo pacientes y notas + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var currentUser: UserWrapper + @EnvironmentObject var navPath: NavigationPathWrapper + + @StateObject var patients = PatientsViewModel() + @StateObject var notes = NotesViewModel() + + //Agregar paciente + @State private var showAddPatient = false + + //Filtrado de Pacientes + @State var search: String = "" + @State private var filteredPatients: [Patient] = [] + @State private var resetFilters = false + + // Variable para rastrear si se ha seleccionado un filtro + @State private var communicationStyleFilterSelected = false + @State private var cognitiveLevelFilterSelected = false + + //opciones de comunicación y nivel cognitivo para filtros + var communicationStyles = ["Verbal", "No-verbal", "Mixto"] + var cognitiveLevels = ["Alto", "Medio", "Bajo"] + + //mostrar opciones de filtrado + @State private var showCognitiveLevelFilterOptions = false + @State private var showCommunicationStyleFilterOptions = false + + //Selección de filtrados + @State private var selectedCommunicationStyle = "" + @State private var selectedCognitiveLevel = "" + + //Selección de filtrados + @State private var showSelectedCommunicationStyle = false + @State private var showSelectedCognitiveLevel = false + + //Hidden NavBar + @State private var showAdminMenu = false + + @State private var selection: String? = nil + + @State private var showAdminView: Bool = false + @State private var showRegisterView: Bool = false + var hiddenNavBar : Bool = false + + //Reseteo de filtrado + // Filtrado + private func resetSearchFilters() { + filteredPatients = [] + selectedCommunicationStyle = "" + selectedCognitiveLevel = "" + search = "" + resetFilters = false + communicationStyleFilterSelected = false + cognitiveLevelFilterSelected = false + } + + private var patientsListDisplayed: [Patient]? { + if communicationStyleFilterSelected || cognitiveLevelFilterSelected || search != "" { + return filteredPatients.isEmpty ? nil : filteredPatients + } + return patients.patientsList + } + + //Busqueda por nombre o apellido + private func performSearchByName(keyword: String){ + + // Convierte la palabra clave a una forma que ignora los diacríticos + let keyword = keyword.folding(options: .diacriticInsensitive, locale: .current) + + var searchingWithFilters = patients.patientsList + + if(communicationStyleFilterSelected){ + searchingWithFilters = searchingWithFilters.filter{ patient in + patient.communicationStyle == selectedCommunicationStyle + } + } + + if(cognitiveLevelFilterSelected){ + searchingWithFilters = searchingWithFilters.filter{ patient in + patient.cognitiveLevel == selectedCognitiveLevel + } + } + + filteredPatients = searchingWithFilters.filter{ patient in + let firstAndLastName = (patient.firstName + " " + patient.lastName).folding(options: .diacriticInsensitive, locale: .current) + let firstAndLastNameComponent = firstAndLastName.lowercased() + let firstNameComponents = patient.firstName.lowercased().split(separator: " ") + let lastNameComponents = patient.lastName.lowercased().split(separator: " ") + + + var firstNameMatch = false + var lastNameMatch = false + + // Busca en cada componente de firstName + for component in firstNameComponents { + if component.hasPrefix(keyword.lowercased()) { + firstNameMatch = true + break + } + } + + // Busca en cada componente de lastName + for component in lastNameComponents { + if component.hasPrefix(keyword.lowercased()) { + lastNameMatch = true + break + } + } + + return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(keyword.lowercased()) || firstAndLastNameComponent.hasPrefix(keyword.lowercased()) || patient.group.folding(options: .diacriticInsensitive, locale: .current).hasPrefix(keyword) || firstAndLastName.hasPrefix(keyword) + + } + } + + //Busqueda por estilo de comunicación + private func performSearchByCommunicationStyle(){ + let search = search.folding(options: .diacriticInsensitive, locale: .current) + + var searchingWithFilters = patients.patientsList + + //filtramos nivel cognitivo + if(cognitiveLevelFilterSelected){ + searchingWithFilters = searchingWithFilters.filter{ patient in + patient.cognitiveLevel == selectedCognitiveLevel + } + } + + //filtramos por busqueda en search bar + if(search != ""){ + searchingWithFilters = searchingWithFilters.filter{ patient in + let firstAndLastName = patient.firstName + " " + patient.lastName + let firstAndLastNameComponent = firstAndLastName.lowercased() + let firstNameComponents = patient.firstName.lowercased().split(separator: " ") + let lastNameComponents = patient.lastName.lowercased().split(separator: " ") + + var firstNameMatch = false + var lastNameMatch = false + + // Busca en cada componente de firstName + for component in firstNameComponents { + if component.hasPrefix(search.lowercased()) { + firstNameMatch = true + break + } + } + + // Busca en cada componente de lastName + for component in lastNameComponents { + if component.hasPrefix(search.lowercased()) { + lastNameMatch = true + break + } + } + + return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(search.lowercased()) || firstAndLastNameComponent.hasPrefix(search.lowercased()) || patient.group.folding(options: .diacriticInsensitive, locale: .current).hasPrefix(search) || firstAndLastNameComponent.folding(options: .diacriticInsensitive, locale: .current).hasPrefix(search) + } + } + + //si no es valida la operación, no filramos + if(selectedCommunicationStyle == "" || selectedCommunicationStyle == "Comunicación"){ + filteredPatients = searchingWithFilters + } + //si es valida la operación, filramos + else{ + filteredPatients = searchingWithFilters.filter{ patient in + patient.communicationStyle == selectedCommunicationStyle + } + } + } + + //Busqueda por nivel cognitivo + private func performSearchByCognitiveLevel(){ + + let search = search.folding(options: .diacriticInsensitive, locale: .current) + + var searchingWithFilters = patients.patientsList + + //filtramos estilo de comunicación + if(communicationStyleFilterSelected){ + searchingWithFilters = searchingWithFilters.filter{ patient in + patient.communicationStyle == selectedCommunicationStyle + } + } + + //filtramos por palabras en el searchbar + if(search != ""){ + searchingWithFilters = searchingWithFilters.filter{ patient in + let firstAndLastName = patient.firstName + " " + patient.lastName + let firstAndLastNameComponent = firstAndLastName.lowercased() + let firstNameComponents = patient.firstName.lowercased().split(separator: " ") + let lastNameComponents = patient.lastName.lowercased().split(separator: " ") + + var firstNameMatch = false + var lastNameMatch = false + + // Busca en cada componente de firstName + for component in firstNameComponents { + if component.hasPrefix(search.lowercased()) { + firstNameMatch = true + break + } + } + + // Busca en cada componente de lastName + for component in lastNameComponents { + if component.hasPrefix(search.lowercased()) { + lastNameMatch = true + break + } + } + + return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(search.lowercased()) || firstAndLastNameComponent.hasPrefix(search.lowercased()) || patient.group.folding(options: .diacriticInsensitive, locale: .current).hasPrefix(search) || firstAndLastNameComponent.folding(options: .diacriticInsensitive, locale: .current).hasPrefix(search) + } + } + + //checamos si es valida la operación + if(selectedCognitiveLevel == "" || selectedCognitiveLevel == "Nivel Cognitivo"){ + filteredPatients = searchingWithFilters + }else{ + filteredPatients = searchingWithFilters.filter{ patient in + patient.cognitiveLevel == selectedCognitiveLevel + } + } + + } + + var body: some View { + VStack{ + + VStack { + + AdminNav(showAdminView: $showAdminView, showRegisterView: $showRegisterView) + + //Search bar y Boton para agregar pacientes + HStack{ + // magnifyingglass + SearchBarView(searchText: $search, placeholder: "Buscar paciente o grupo", searchBarWidth: 250) + .onChange(of: search, perform: performSearchByName) + + Spacer() + //Boton para añadir paciente + if currentUser.isAdmin! { + Button(action: { + showAddPatient.toggle() + }) { + HStack { + Image(systemName: "plus.circle.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Agregar Paciente") + } + } + .padding(10) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + + HStack{ + + if currentUser.isAdmin! { + Menu { + + if currentUser.isAdmin! { + Button { + navPath.push(NavigationDestination(content: PictogramEditor(patient: nil))) + } label: { + Text("Editar comunicador base") + Image(systemName: "pencil") + } + } + + Button { + navPath.push(NavigationDestination(content: SingleCommunicator(patient: nil))) + } label: { + Text("Acceder a comunicador base") + Image(systemName: "message.fill") + } + } label: { + HStack { + Image(systemName: "ellipsis.circle.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Comunicador base") + } + .padding(10) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + }else{ + Button(action: { + navPath.push(NavigationDestination(content: SingleCommunicator(patient: nil))) + }) { + HStack { + Image(systemName: "message.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Comunicador base") + } + } + .padding(10) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + } + .padding(.horizontal, 50) + .padding(.vertical, 10) + //.padding(.vertical) + + // Filtrado + HStack{ + + Text("Filtrado") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color.gray) + .padding(.trailing) + + Divider() + + HStack { + // Nivel cognitivo + //================================================================================================== + ZStack { + Picker("Nivel Cognitivo", selection: $selectedCognitiveLevel) { + if !cognitiveLevelFilterSelected { + Text("Nivel Cognitivo").tag("") + .foregroundColor(.white) + }else{ + Text("Eliminar filtro").tag("") + } + + + ForEach(cognitiveLevels, id: \.self) { + Text("Nivel Cognitivo " + $0) + } + } + .onChange(of: selectedCognitiveLevel, perform: { value in + performSearchByCognitiveLevel() + cognitiveLevelFilterSelected = selectedCognitiveLevel != "" && selectedCognitiveLevel != "Eliminar filtro" + }) + .pickerStyle(MenuPickerStyle()) + .frame(width: 210, height: 40) + + if cognitiveLevelFilterSelected { + Button(action: { + selectedCognitiveLevel = "" + cognitiveLevelFilterSelected = false + }) { + HStack { + } + } + .background(.white) + .cornerRadius(10) + } + } + .frame(width: 210, height: 40) + .padding(.top, 10) + .padding(.bottom, 10) + + // Comunicación + //============================================================ + ZStack { + Picker("Comunicación", selection: $selectedCommunicationStyle) { + if !communicationStyleFilterSelected { + Text("Tipo de Comunicación").tag("") + .foregroundColor(.white) + }else{ + Text("Eliminar filtro").tag("") + } + + + ForEach(communicationStyles, id: \.self) { + Text("Comunicación " + $0) + } + } + .onChange(of: selectedCommunicationStyle, perform: { value in + performSearchByCommunicationStyle() + communicationStyleFilterSelected = selectedCommunicationStyle != "" && selectedCommunicationStyle != "Eliminar filtro" + }) + .pickerStyle(MenuPickerStyle()) + .frame(width: 240, height: 40) + + if communicationStyleFilterSelected { + Button(action: { + selectedCommunicationStyle = "" + communicationStyleFilterSelected = false + }) { + HStack { + } + } + .background(.white) + .cornerRadius(10) + } + } + .frame(width: 240, height: 40) + .padding(.top, 10) + .padding(.bottom, 10) + } + + Spacer() + } + .frame(maxHeight: 50) + .padding(.horizontal, 50) + .padding(.bottom, 10) + + //mostramos que no existe pacientes con los filtros seleccionados + if(patientsListDisplayed == nil){ + + if(patients.patientsList.count == 0){ + List{ + HStack{ + Spacer() + VStack { + Text("Aún no hay pacientes") + .font(.title2) + .foregroundColor(Color.gray) + .padding() + Text("Los pacientes que agregues se mostrarán en esta pantalla :)") + .font(.headline) + .foregroundColor(Color.gray) + } + //.padding(.top, 150) + Spacer() + } + .padding() + .background(Color.white) + .cornerRadius(10) + .padding([.leading, .trailing, .bottom, .top], 10) + } + .listStyle(.automatic) + .onChange(of: patients.patientsList, perform: {value in + resetSearchFilters() + }) + .sheet(isPresented: $showAddPatient) { + AddPatientView(patients:patients) + } + } + else{ + List{ + HStack{ + Spacer() // Espacio superior + Text("No se han encontrado pacientes con ese filtrado.") + .font(.title2) + .foregroundColor(Color.gray) + Spacer() // Espacio inferior + } + .padding() + .background(Color.white) + .cornerRadius(10) + .padding([.leading, .trailing, .bottom, .top], 10) + } + //.background(Color.gray.opacity(0.1)) + .listStyle(.automatic) + .onChange(of: patients.patientsList, perform: {value in + resetSearchFilters() + }) + .sheet(isPresented: $showAddPatient) { + AddPatientView(patients: patients) + } + + } + Spacer() + } + else{ + //mostramos lista de pacientes + List(patientsListDisplayed ?? patients.patientsList, id:\.id){ patient in + + ZStack(alignment: .trailing) { + PatientCardView(patient: patient) + .padding() + .background(Color.white) + .cornerRadius(10) + .padding([.leading, .trailing, .bottom], 10) + .contentShape(Rectangle()) // Importante para detectar toques en toda el área + .onTapGesture { + navPath.push(NavigationDestination(content: PatientView(patients: patients, notes: notes, patient: patient))) + } + + Button { + navPath.push(NavigationDestination(content: DoubleCommunicator(patient: patient))) + } label: { + Text("Comunicador") + .padding(10) + .padding([.leading, .trailing], 15) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(10) + } + .padding(.trailing, 20) + } + } + .listStyle(.automatic) + .onChange(of: patients.patientsList, perform: {value in + resetSearchFilters() + }) + .sheet(isPresented: $showAddPatient) { + AddPatientView(patients:patients) + } + .sheet(isPresented: $showAdminMenu){ + AdminMenuView(user: currentUser.getUser()!) + } + } + } + } + .sheet(isPresented: $showAdminView){ + AdminMenuView(user: currentUser.getUser()!) + } + .sheet(isPresented: $showRegisterView){ + RegisterView() + } + .navigationBarBackButtonHidden(true) + } +} diff --git a/nuevoamanecer/Admin/View/Admin/AdminDashboard/PatientCardView.swift b/nuevoamanecer/Admin/View/Admin/AdminDashboard/PatientCardView.swift new file mode 100644 index 0000000..1cd6c64 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/AdminDashboard/PatientCardView.swift @@ -0,0 +1,109 @@ +// +// PatientCardView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 06/06/23. +// + +import SwiftUI +import Kingfisher + +struct PatientCardView: View { + + let patient: Patient + + //obtiene edad del paciente + private func getAge(patient: Patient) -> Int { + let birthday: Date = patient.birthDate // tu fecha de nacimiento aquí + let calendar: Calendar = Calendar.current + + let ageComponents = calendar.dateComponents([.year], from: birthday, to: Date()) + let age = ageComponents.year! + + return age + } + + var body: some View{ + VStack(alignment: .leading) { + HStack { + if(patient.image == "placeholder") { + ImagePlaceholderView(firstName: patient.firstName, lastName: patient.lastName) + .padding(.trailing) + } else { + KFImage(URL(string: patient.image)) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(Circle()) + .padding(.trailing) + } + + VStack(alignment: .leading) { + + HStack{ + Text(patient.firstName + " " + patient.lastName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(Color.black) + .padding(.vertical, 1) + + Text(String(getAge(patient: patient)) + " años" ) + .font(.headline) + .foregroundColor(Color.gray) + .padding(.vertical, 1) + } + + + VStack(alignment: .leading){ + Text("Grupo: " + patient.group) + .font(.subheadline) + //.foregroundColor(Color.gray) + .padding(.trailing) + .padding(.vertical,1) + + Text("Nivel Cognitivo: " + patient.cognitiveLevel) + .font(.subheadline) + //.foregroundColor(Color.gray) + .padding(.trailing) + .padding(.vertical,1) + + Text("Comunicación: " + patient.communicationStyle) + .font(.subheadline) + //.foregroundColor(Color.gray) + .padding(.trailing) + .padding(.vertical,1) + } + + } + .padding(.leading) + + Spacer() + /* + Button(action: { + print("Comunicador") + }) { + HStack { + Image(systemName: "message.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Comunicador") + .font(.headline) + + } + + .padding(10) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + */ + } + .padding(.horizontal) + } + .padding(.vertical, 5) + } +} +struct PatientCardView_Previews: PreviewProvider { + static var previews: some View { + PatientCardView(patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String](), identificador: "")) + } +} diff --git a/nuevoamanecer/Admin/View/Admin/PatientProfile/AddNoteView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/AddNoteView.swift new file mode 100644 index 0000000..0de6cee --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/AddNoteView.swift @@ -0,0 +1,156 @@ +// +// AddNoteView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 25/05/23. +// + +import SwiftUI + + +struct AddNoteView: View { + @Environment(\.dismiss) var dismiss + @ObservedObject var notes: NotesViewModel + @Binding var filteredNotes: [Note] + @Binding var search: String + + var patient: Patient + @State private var noteTitle: String = "" + @State private var noteContent: String = "" + @State private var showingAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" + + //tags + let tags: [String] = ["Información Personal","Contacto","Historial Médico","Diagnóstico","Tratamiento","Soporte Familiar","Consentimientos","Otro"] + @State private var selectedTag: String = "" + + @State var isSaving : Bool = false + + var body: some View { + VStack { + Form { + Section(header: Text("Título")) { + TextField("Introduce el título de la nota", text: $noteTitle) + } + + Section(header: Text("Etiqueta")) { + Picker("Seleccionar Etiqueta", selection: $selectedTag) { + Text("Ninguna").tag("") + ForEach(tags, id: \.self) { tag in + Text(tag).tag(tag) + } + } + .pickerStyle(MenuPickerStyle()) + } + + Section(header: Text("Contenido")) { + //Dynamic expand + TextField("Contenido de la nota", text: $noteContent, axis: .vertical) + //.frame(minHeight: 450) + + } + } + + HStack{ + //Cancel + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + //Save + Button(action: { + if noteTitle.isEmpty || noteContent.isEmpty { + self.alertTitle = "Faltan campos" + self.alertMessage = "Por favor, rellena todos los campos antes de guardar la nota." + self.showingAlert = true + } + else if !isValidInputNoWhiteSpaces(input: noteTitle) || !isValidInputNoWhiteSpaces(input: noteContent){ + self.alertTitle = "Texto no válido" + self.alertMessage = "El título y el contenido no pueden contener solamente espacios en blanco" + self.showingAlert = true + } + else if hasLeadingWhitespace(input: noteTitle) || hasLeadingWhitespace(input: noteContent){ + self.alertTitle = "Texto no válido" + self.alertMessage = "El título y el contenido no pueden iniciar con campos en blanco" + self.showingAlert = true + } + else { + isSaving = true + let newNote = Note(id: UUID().uuidString, patientId: patient.id, order: (patient.notes.count * -1) - 1, title: removeTrailingWhitespace(from: noteTitle) , text: removeTrailingWhitespace(from: noteContent) , date: Date(), tag: selectedTag) + + notes.addData(patient: patient, note: newNote) { response in + if response == "OK" { + self.alertTitle = "Nota guardada" + self.alertMessage = "La nota ha sido guardada con éxito." + self.showingAlert = false + + search = "" + + Task{ + if let notesList = await notes.getDataById(patientId: patient.id){ + DispatchQueue.main.async{ + self.notes.notesList = notesList.sorted { $0.order < $1.order } + self.filteredNotes = self.notes.notesList + dismiss() + } + } + } + } else { + self.alertTitle = "Error" + self.alertMessage = response + self.showingAlert = true + } + } + } + }) { + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .allowsHitTesting(!isSaving) + .alert(isPresented: $showingAlert) { + Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } + } + } + .padding() + .background(Color(.init(white: 0, alpha: 0.05)) + .ignoresSafeArea()) + } +} + +struct AddNoteView_Previews: PreviewProvider { + static var previews: some View { + PreviewWrapper() + } + + struct PreviewWrapper: View { + @State(initialValue: []) var previewFilteredNotes: [Note] + @State(initialValue: "") var previewSearch: String // Nueva propiedad + + var body: some View { + AddNoteView(notes: NotesViewModel(), filteredNotes: $previewFilteredNotes, search: $previewSearch, patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String](), identificador: "")) + } + } +} diff --git a/nuevoamanecer/View/DeletePatientView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/DeletePatientView.swift similarity index 70% rename from nuevoamanecer/View/DeletePatientView.swift rename to nuevoamanecer/Admin/View/Admin/PatientProfile/DeletePatientView.swift index 3ae86a7..e9e228d 100644 --- a/nuevoamanecer/View/DeletePatientView.swift +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/DeletePatientView.swift @@ -8,30 +8,40 @@ import SwiftUI struct DeletePatientView: View { - @Environment(\.dismiss) var dismiss @ObservedObject var patients: PatientsViewModel @State var patient: Patient @State private var showAlert = false + @State private var storage = FirebaseAlmacenamiento() + + @EnvironmentObject var pathWrapper: NavigationPathWrapper var body: some View { - VStack { + HStack { Button(action: { showAlert = true }) { - Text("Eliminar Paciente") - .font(.system(size: 16, weight: .bold)) - .padding(.horizontal) - .padding(.vertical, 10) - .background(Color.red) - .foregroundColor(.white) - .cornerRadius(10) + HStack { + Text("Eliminar") + .font(.system(size: 16)) + + Spacer() + + Image(systemName: "trash") + .font(.system(size: 16)) + } } + .padding() + .background(Color.red) + .cornerRadius(10) + .foregroundColor(.white) + .frame(maxWidth: 170) .alert(isPresented: $showAlert) { () -> Alert in Alert(title: Text("Confirmar Eliminación"), message: Text("¿Estás seguro de que quieres eliminar a este paciente? Esta acción no se puede deshacer."), primaryButton: .destructive(Text("Eliminar")) { // Aquí va la lógica para eliminar al paciente Task{ + storage.deleteFile(name: "Fotos_perfil/" + patient.identificador + "profile_picture") await patients.deleteData(patient: patient){ error in if error != "OK"{ print(error) @@ -44,23 +54,23 @@ struct DeletePatientView: View { } } } - dismiss() + pathWrapper.returnToRoot() } } } }, secondaryButton: .cancel()) - } + Spacer() } - .padding() } } struct DeletePatientView_Previews: PreviewProvider { static var previews: some View { - DeletePatientView(patients: PatientsViewModel(), patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String]())) + DeletePatientView(patients: PatientsViewModel(), patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String](), identificador: "")) } } + diff --git a/nuevoamanecer/Admin/View/Admin/PatientProfile/EditNoteView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/EditNoteView.swift new file mode 100644 index 0000000..ea81f83 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/EditNoteView.swift @@ -0,0 +1,136 @@ +// +// EditNoteView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 30/05/23. +// + +import SwiftUI + +struct EditNoteView: View { + @Environment(\.dismiss) var dismiss + @ObservedObject var notes: NotesViewModel + @Binding var filteredNotes: [Note] // Nueva propiedad + @State var note: Note + @State private var noteTitle: String = "" + @State private var noteContent: String = "" + @State private var showingAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" + + //tags + let tags: [String] = ["Información Personal","Contacto","Historial Médico","Diagnóstico","Tratamiento","Soporte Familiar","Consentimientos","Otro"] + @State private var selectedTag: String = "" + + + func initializeData(note: Note) -> Void{ + noteTitle = note.title + noteContent = note.text + selectedTag = note.tag + } + + var body: some View { + VStack { + + Form { + Section(header: Text("Título")) { + TextField("Título de la nota", text: $noteTitle) + } + + Section(header: Text("Etiqueta")) { + Picker("Seleccionar Etiqueta", selection: $selectedTag) { + Text("Ninguna").tag("") + ForEach(tags, id: \.self) { tag in + Text(tag).tag(tag) + } + } + .pickerStyle(MenuPickerStyle()) + } + + Section(header: Text("Contenido")) { + TextEditor(text: $noteContent) + //.fixedSize(horizontal: false, vertical: true) + .frame(minHeight: 450) + } + } + + HStack{ + //Cancel + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + + //Save + Button(action: { + if noteTitle.isEmpty || noteContent.isEmpty { + self.alertTitle = "Faltan campos" + self.alertMessage = "Por favor, rellena todos los campos antes de guardar la nota." + self.showingAlert = true + } + else if !isValidInputNoWhiteSpaces(input: noteTitle) || !isValidInputNoWhiteSpaces(input: noteContent){ + self.alertTitle = "Texto no válido" + self.alertMessage = "El título y el contenido no pueden contener solamente espacios en blanco" + self.showingAlert = true + } + else if hasLeadingWhitespace(input: noteTitle) || hasLeadingWhitespace(input: noteContent){ + self.alertTitle = "Texto no válido" + self.alertMessage = "El título y el contenido no pueden iniciar con campos en blanco" + self.showingAlert = true + } + else { + //let newNote = Note(id: note.id, patientId: note.patientId, order: note.order, title: noteTitle, text: noteContent) + self.note.title = removeTrailingWhitespace(from: noteTitle) + self.note.text = removeTrailingWhitespace(from: noteContent) + self.note.tag = selectedTag + + self.notes.updateData(note: note){ error in + if error != "OK" { + self.alertTitle = "Error" + self.alertMessage = "Se produjo un error al guardar los cambios." + self.showingAlert = true + } else { + self.notes.notesList = self.notes.notesList.map {$0.id == note.id ? note : $0} + self.filteredNotes = self.filteredNotes.map {$0.id == note.id ? note : $0} + + dismiss() + } + } + } + }) { + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .alert(isPresented: $showingAlert) { + Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } + + } + } + .padding() + .background(Color(.init(white: 0, alpha: 0.05)) + .ignoresSafeArea()) + .onAppear{initializeData(note: note)} + } +} diff --git a/nuevoamanecer/Admin/View/Admin/PatientProfile/EditPatientView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/EditPatientView.swift new file mode 100644 index 0000000..a1c0a0d --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/EditPatientView.swift @@ -0,0 +1,367 @@ +//EditPatientView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 26/05/23. + +// Importación de bibliotecas y módulos necesarios +import SwiftUI +import FirebaseStorage +import SDWebImageSwiftUI +import Kingfisher +import Foundation + +// Definición de la vista EditPatientView +struct EditPatientView: View { + // Objeto observado que contiene el modelo de vista de pacientes + @ObservedObject var patients: PatientsViewModel + // Estado que contiene la información del paciente que se está editando + @Binding var patient: Patient + // Variable de entorno para cerrar la vista + @Environment(\.dismiss) var dismiss + + // Lista de niveles cognitivos + var cognitiveLevels = ["Alto", "Medio", "Bajo"] + // Selector de nivel cognitivo + @State private var congnitiveLevelSelector = "" + + // Lista de estilos de comunicación + var communicationStyles = ["Verbal", "No-verbal", "Mixto"] + // Selector de estilo de comunicación + @State private var communicationStyleSelector = "" + + // Variables de estado para controlar la interfaz de usuario y la lógica de la vista + @State var showAlert : Bool = false + @State private var showAlertNoImage = false + @State private var firstName : String = "" + @State private var lastName : String = "" + @State private var birthDate : Date = Date.now + @State private var group : String = "" + @State private var upload_image: UIImage? + @State private var deletedImage = false + @State private var identificador : String = "" + + @State private var errorTitle: String = "" + @State private var errorMessage: String = "" + @State private var storage = FirebaseAlmacenamiento() + @State private var shouldShowImagePicker = false + @State private var imageURL = URL(string: "") + @State private var uploadPatient: Bool = false + + + // Función para inicializar los datos del paciente en la vista + func initializeData(patient: Patient) -> Void{ + firstName = patient.firstName + lastName = patient.lastName + birthDate = patient.birthDate + group = patient.group + communicationStyleSelector = patient.communicationStyle + congnitiveLevelSelector = patient.cognitiveLevel + identificador = patient.id + } + + + // Función para cargar la imagen del paciente desde Firebase + func loadImageFromFirebase(name:String) { + let storageRef = Storage.storage().reference(withPath: name) + storageRef.downloadURL { (url, error) in + if error != nil { + print("Este usuario no tiene una imagen de perfil") + return + } + self.imageURL = url! + } + } + + + var body: some View { + // Contenedor vertical que agrupa los elementos de la vista + VStack{ + // Vista para eliminar al paciente + DeletePatientView(patients:patients, patient: patient) + //Form + VStack{ + Form { + Section(header: Text("Foto del paciente")) { + HStack{ + Spacer() + Menu { + Button(action: { + shouldShowImagePicker.toggle() + deletedImage = false + + }, label: { + Text("Seleccionar Imagen") + }) + Button(action: { + if (patient.image == "placeholder" || deletedImage) { + showAlertNoImage.toggle() + } else { + deletedImage = true + } + }, label: { + Text("Eliminar Imagen") + }) + } label: { + + //Imagen recien cargada + if let displayImage = self.upload_image { + + ZStack{ + Image(uiImage: displayImage) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + //.foregroundColor(.white) + } + .padding(.horizontal, 20) + } else { + + //No imagen + if(patient.image == "placeholder" || deletedImage) { + ZStack{ + Text(patient.firstName.prefix(1) + patient.lastName.prefix(1)) + .textCase(.uppercase) + .font(.title) + .fontWeight(.bold) + .frame(width: 128, height: 128) + .background(Color(.systemGray3)) + .foregroundColor(.white) + .clipShape(Circle()) + .padding(.trailing) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + //.foregroundColor(.white) + } + .padding(.horizontal, 20) + } + + //Imagen previamente subida + else{ + + ZStack{ + KFImage(URL(string: patient.image)) + .resizable() + .scaledToFill() + .frame(width: 128, height: 128) + .cornerRadius(128) + .padding(.horizontal, 20) + + Image(systemName: "photo.on.rectangle.fill") + .font(.system(size: 25)) + .offset(x: 53, y: 50) + .foregroundColor(.blue) + } + .padding(.horizontal, 20) + } + } + } + Spacer() + } + .alert("Error", isPresented: $showAlertNoImage){ + Button("Ok") {} + } + message: { + Text("Este perfil no cuenta con imagen") + } + } + Section(header: Text("Información del Paciente")) { + TextField("Primer Nombre", text: $firstName) + TextField("Apellidos", text: $lastName) + TextField("Grupo", text: $group) + DatePicker("Fecha de nacimiento", selection: $birthDate, displayedComponents: .date) + } + + Section(header: Text("Nivel Cognitivo")) { + Picker("Nivel Cognitivo", selection: $congnitiveLevelSelector) { + Text("") + ForEach(cognitiveLevels, id: \.self) { + Text($0) + } + } + } + + Section(header: Text("Estilo de Comunicación")) { + Picker("Tipo de comunicación", selection: $communicationStyleSelector) { + Text("") + ForEach(communicationStyles, id: \.self) { + Text($0) + } + } + } + } + } + + + //Buttons + HStack{ + //Cancel + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + + //Save + Button(action: { + //Subir imagen a firebase + if let thisImage = self.upload_image { + Task { + await storage.uploadImage(image: thisImage, name: "Fotos_perfil/" + patient.identificador + "profile_picture") { url in + + self.imageURL = url + + //Validamos que no existan campos vaciós + if(firstName == "" || lastName == "" || group == "" || communicationStyleSelector == "" || congnitiveLevelSelector == ""){ + errorTitle = "Campos vacíos" + errorMessage = "Todos los campos deben ser llenados." + showAlert = true + } + //Validamos que no existán caracteres especiales en nombre + else if(!isValidName(name: firstName) || !isValidName(name: lastName)){ + errorTitle = "Favor de volver a ingresar sus datos" + errorMessage = "Nombre y apellido del paciente deben contener solamente letras y no tener espacios en blanco al inicio." + firstName = "" + lastName = "" + showAlert = true + } + //Validamos que el grupo tenga un nombre valido. solo letras y numeros + else if(!isValidOnlyCharAndNumbers(input: group)){ + errorTitle = "Nombre de grupo inválido" + errorMessage = "Nombre del grupo debe contener solamente letras, números y no tener espacios en blanco al inicio." + group = "" + showAlert = true + } + //Validamos que la fecha de nacimiento no sea en el futuro o en el mes previo + //La razón del mes previo es para promover que escriban la fecha de nacimiento correcta + else if(!isValidBirthDate(birthDate: birthDate)){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Ingresa una fecha de nacimiento previa a un mes." + showAlert = true + } + //Actualización de datos en la base de datos + else { + uploadPatient.toggle() + dismiss() + } + } + } + } else { + + //Validamos que no existan campos vaciós + if(firstName == "" || lastName == "" || group == "" || communicationStyleSelector == "" || congnitiveLevelSelector == ""){ + errorTitle = "Campos vacíos" + errorMessage = "Todos los campos deben ser llenados." + showAlert = true + } + //Validamos que no existán caracteres especiales en nombre + else if(!isValidName(name: firstName) || !isValidName(name: lastName)){ + errorTitle = "Favor de volver a ingresar sus datos" + errorMessage = "Nombre y apellido del paciente deben contener solamente letras y no tener espacios en blanco al inicio." + firstName = "" + lastName = "" + showAlert = true + } + //Validamos que el grupo tenga un nombre valido. solo letras y numeros + else if(!isValidOnlyCharAndNumbers(input: group)){ + errorTitle = "Nombre de grupo inválido" + errorMessage = "Nombre del grupo debe contener solamente letras, números y no tener espacios en blanco al inicio." + group = "" + showAlert = true + } + //Validamos que la fecha de nacimiento no sea en el futuro o en el mes previo + //La razón del mes previo es para promover que escriban la fecha de nacimiento correcta + else if(!isValidBirthDate(birthDate: birthDate)){ + errorTitle = "Fecha de nacimiento inválida" + errorMessage = "Ingresa una fecha de nacimiento previa a un mes." + showAlert = true + } + //Actualización de datos en la base de datos + else { + uploadPatient.toggle() + dismiss() + } + + } + }){ + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .cornerRadius(10) + .foregroundColor(.white) + + .alert(errorTitle, isPresented: $showAlert){ + Button("Ok") {} + } + message: { + Text(errorMessage) + } + + } + } + .padding() + .background(Color(.init(white: 0, alpha: 0.05)) + .ignoresSafeArea()) + .fullScreenCover(isPresented: $shouldShowImagePicker, onDismiss: nil) { + ImagePicker(image: $upload_image) + } + .onAppear{ + initializeData(patient: patient) + loadImageFromFirebase(name: "Fotos_perfil/" + patient.identificador + "profile_picture.jpg") + } + .onDisappear{ + if(uploadPatient){ + let backupPatient = patient + //Si se presiono el boton de eliminar imagen y despues guardar, se borra la imagen de la base de datos + if(deletedImage) { + storage.deleteFile(name: "Fotos_perfil/" + patient.identificador + "profile_picture") + imageURL = URL(string: "placeholder") + } + let patient = Patient(id: patient.id ,firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyleSelector, cognitiveLevel: congnitiveLevelSelector, image: imageURL?.absoluteString ?? "placeholder", notes: [String](), identificador: patient.identificador) + self.patient = patient + //call method for update + patients.updateData(patient: patient){ error in + if error != "OK" { + print(error) + self.patient = backupPatient + }else{ + Task { + if let patientsList = await patients.getData(){ + DispatchQueue.main.async { + self.patients.patientsList = patientsList + dismiss() + } + } + } + } + } + } + } + } +} + diff --git a/nuevoamanecer/Admin/View/Admin/PatientProfile/NoteCardView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/NoteCardView.swift new file mode 100644 index 0000000..3ead6e8 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/NoteCardView.swift @@ -0,0 +1,86 @@ +// +// NoteCardView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 06/06/23. +// + +import SwiftUI + +struct NoteCardView: View { + var note: Note + + var body: some View { + VStack{ + HStack{ + Spacer() + Text(note.date, formatter: DateFormatter.noteCardFormatter) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(Color.gray) + .padding(.trailing) + } + + HStack{ + VStack(alignment: .leading){ + Text(note.title) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(Color.black) + .padding(.bottom, 2) + .fixedSize(horizontal: false, vertical: true) + + + Text(note.text) + .font(.system(size: 18, weight: .regular)) + .foregroundColor(Color.black) + .padding([.bottom, .top, .trailing]) + .fixedSize(horizontal: false, vertical: true) + + } + Spacer() + } + .padding([.bottom], 10) + .padding([.leading, .trailing], 15) + .background(Color.white.opacity(0.1)) + .cornerRadius(10) + //.shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2) + + HStack { + Spacer() + VStack(alignment: .leading) { + Text(note.tag) + .frame(width: 180) + .foregroundColor(.white) + .padding(5) + } + .background( + RoundedRectangle(cornerRadius: 10) + .fill( + note.tag == "Información Personal" ? Color.orange : + note.tag == "Contacto" ? Color.red : + note.tag == "Historial Médico" ? Color.pink : + note.tag == "Diagnóstico" ? Color.purple : + note.tag == "Tratamiento" ? Color.yellow : + note.tag == "Soporte Familiar" ? Color.cyan : + note.tag == "Consentimientos" ? Color.green : + note.tag == "Contacto" ? Color.teal : + note.tag == "Otro" ? Color.black : Color.clear + ) + ) + .frame(height: 30) + .cornerRadius(10) // Applying cornerRadius to VStack + + } + .padding() + } + } +} + + +extension DateFormatter { + static var noteCardFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + return formatter + }() +} diff --git a/nuevoamanecer/Admin/View/Admin/PatientProfile/PatientView.swift b/nuevoamanecer/Admin/View/Admin/PatientProfile/PatientView.swift new file mode 100644 index 0000000..ff67988 --- /dev/null +++ b/nuevoamanecer/Admin/View/Admin/PatientProfile/PatientView.swift @@ -0,0 +1,596 @@ +// +// PatientView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 19/05/23. +// + +import SwiftUI +import Kingfisher + +struct PatientView: View { + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var currentUser: UserWrapper + @EnvironmentObject var navPath: NavigationPathWrapper + + //ViewModels + @ObservedObject var patients : PatientsViewModel + @ObservedObject var notes : NotesViewModel + @StateObject var auth = AuthViewModel() + + //patient + @State var patient: Patient + @State var search: String = "" + @State private var filteredNotes: [Note] = [] + + //tags + let tags: [String] = ["Información Personal","Contacto","Historial Médico","Diagnóstico","Tratamiento","Soporte Familiar","Consentimientos","Otro"] + @State private var selectedTag: String = "" + @State private var tagSelected: Bool = false + + //showViews + @State var showAddNoteView: Bool = false + @State var showEditPatientView: Bool = false + @State private var showDeleteNoteAlert: Bool = false + @State var showEditNoteView: Bool = false + + //Note Selection + @State private var selectedNoteIndex: Int? + @State var selectedNoteToEdit: Note? + //= Note(id: "", patientId: "", order: 0, title: "", text: "") + + @State private var showCommunicatorMenu: Bool = false + + @State private var selection: String? = nil + + init(patients: PatientsViewModel, notes: NotesViewModel, patient: Patient) { + self.patients = patients + self.notes = notes + self._patient = State(initialValue: patient) + } + + //obtiene edad del paciente + private func getAge(patient: Patient) -> Int { + let birthday: Date = patient.birthDate // tu fecha de nacimiento aquí + let calendar: Calendar = Calendar.current + + let ageComponents = calendar.dateComponents([.year], from: birthday, to: Date()) + let age = ageComponents.year! + + return age + } + + //Retrieve Notes of patient + private func getPatientNotes(patientId: String){ + Task{ + if let notesList = await notes.getDataById(patientId: patient.id){ + DispatchQueue.main.async{ + self.notes.notesList = notesList.sorted { $0.order < $1.order } + self.filteredNotes = self.notes.notesList + } + } + } + } + + + func moveNote(from source: IndexSet, to destinationIdx: Int) { + guard let sourceIdx: Int = source.first else { return } + let adjustDestination: Bool = destinationIdx > sourceIdx + + notes.notesList.moveItem(from: notes.notesList.firstIndex(of: filteredNotes[sourceIdx])!, + to: notes.notesList.firstIndex(of: filteredNotes[destinationIdx - (adjustDestination ? 1 : 0)])!) + filteredNotes.moveItem(from: sourceIdx, to: destinationIdx - (adjustDestination ? 1 : 0)) + + // Considerar: el valor de 'order' de los elementos de filteredNotes no es actualizado. + for i in 0..(content: PictogramEditor(patient: patient))) + } label: { + Text("Editar comunicador de \(patient.firstName)") + Image(systemName: "pencil") + } + } + + Button { + navPath.push(NavigationDestination(content: DoubleCommunicator(patient: patient))) + } label: { + Text("Acceder a comunicador de \(patient.firstName)") + Image(systemName: "message.fill") + } + + /* + Button { + pathWrapper.push(data: NavigationDestination(viewType: .album, userId: patient.id)) + } label: { + Text("Acceder a album de \(patient.firstName)") + Image(systemName: "message.fill") + } + */ + + } label: { + HStack { + Image(systemName: "ellipsis.circle.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Comunicador de \(patient.firstName)") + .font(.headline) + } + .padding(10) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(10) + } + }else{ + Button(action: { + // Handle settings action here + navPath.push(NavigationDestination(content: DoubleCommunicator(patient: patient))) + }) { + HStack{ + Image(systemName: "message.fill") + .resizable() + .frame(width: 20, height: 20) + Text("Comunicador de \(patient.firstName)") + } + .padding(10) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(10) + //.padding(10) + //.frame(width: 157, height: 40) + } + } + } + } + } + .padding(10) + .padding(.horizontal, 50) + .padding(.bottom, 20) + Spacer() + + + Divider() + + //notes + HStack{ + // 1/4 of the screen for the notes list + VStack { + + //Add note button + HStack{ + Button(action: { + showAddNoteView.toggle() + }) { + HStack { + Image(systemName: "plus.circle.fill") + Text("Agregar Nota") + } + .frame(width: geometry.size.width / 6) + } + } + .padding(.vertical, 15) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .padding(.top, 20) + .padding(.bottom, 10) + + + SearchBarView(searchText: $search, placeholder: "Buscar nota", searchBarWidth: geometry.size.width / 6) + .onChange(of: search, perform: performSearchByText) + .padding(.bottom, 10) + .padding(.top, 15) + + ZStack { + Picker("Filtrar Etiquetas", selection: $selectedTag) { + if !tagSelected { + Text("Filtrar Etiquetas").tag("") + .foregroundColor(.white) + }else{ + Text("Eliminar filtro").tag("") + } + + + ForEach(tags, id: \.self) { + Text($0) + } + } + .foregroundColor( + selectedTag == "Información Personal" ? Color.orange : + selectedTag == "Contacto" ? Color.red : + selectedTag == "Historial Médico" ? Color.pink : + selectedTag == "Diagnóstico" ? Color.purple : + selectedTag == "Tratamiento" ? Color.yellow : + selectedTag == "Soporte Familiar" ? Color.cyan : + selectedTag == "Consentimientos" ? Color.green : + selectedTag == "Contacto" ? Color.teal : + selectedTag == "Otro" ? Color.black : Color.blue + ) + .onChange(of: selectedTag, perform: { value in + filterNotesByTag() + tagSelected = selectedTag != "" && selectedTag != "Eliminar filtro" + }) + .pickerStyle(MenuPickerStyle()) + .frame(width: geometry.size.width / 6) + + if tagSelected { + Button(action: { + selectedTag = "" + tagSelected = false + }) { + HStack { + } + } + .background(.white) + .cornerRadius(10) + } + } + .frame(minWidth: geometry.size.width / 6) + .padding(.top, 10) + .padding(.bottom, 10) + + HStack{ + } + .frame(width: geometry.size.width / 4, height: 15) + .background( + selectedTag == "Información Personal" ? Color.orange : + selectedTag == "Contacto" ? Color.red : + selectedTag == "Historial Médico" ? Color.pink : + selectedTag == "Diagnóstico" ? Color.purple : + selectedTag == "Tratamiento" ? Color.yellow : + selectedTag == "Soporte Familiar" ? Color.cyan : + selectedTag == "Consentimientos" ? Color.green : + selectedTag == "Contacto" ? Color.teal : + selectedTag == "Otro" ? Color.black : Color.clear + ) + + //checamos si hay notas + if (filteredNotes.count == 0){ + + List{ + HStack{ + + Spacer() // Espacio superior + Text("Aquí podrás ver el orden de tus notas") + .foregroundColor(Color.gray) + .fixedSize(horizontal: false, vertical: true) + Spacer() // Espacio inferior + + } + } + .listStyle(.sidebar) + + }else{ + List(filteredNotes, id: \.id) { note in + + Button(action:{selectedNoteIndex = notes.notesList.firstIndex(where: { $0.id == note.id })}){ + HStack{ + + Text(note.title) + .font(.system(size: 18, weight: .light)) + .foregroundColor(selectedNoteIndex == notes.notesList.firstIndex(where: { $0.id == note.id }) ? Color.blue : Color.black) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 50, maxHeight: .infinity, alignment: .leading) + //.frame(width: geometry.size.width / 5, alignment: .leading) + + Circle() + .frame(minHeight: 7, maxHeight: 10) + .foregroundColor( + note.tag == "Información Personal" ? Color.orange : + note.tag == "Contacto" ? Color.red : + note.tag == "Historial Médico" ? Color.pink : + note.tag == "Diagnóstico" ? Color.purple : + note.tag == "Tratamiento" ? Color.yellow : + note.tag == "Soporte Familiar" ? Color.cyan : + note.tag == "Consentimientos" ? Color.green : + note.tag == "Contacto" ? Color.teal : + note.tag == "Otro" ? Color.black : Color.clear + ) + } + + + } + } + //.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listStyle(.sidebar) + + } + } + //.frame(width: UIScreen.main.bounds.width / 4) + .frame(width: geometry.size.width / 4) + .background(Color.white.opacity(0.1)) + //.background(Color.red) + + Divider() + // 3/4 of the screen for patient information and notes + VStack { + + //Checamos que existan pacientes + if(filteredNotes.count == 0){ + List{ + HStack{ + Spacer() + VStack { + Spacer() + Text("Agrega, ordena y edita las notas del expediente") + .font(.title2) + .foregroundColor(Color.black) + .multilineTextAlignment(.center) + .padding() + .fixedSize(horizontal: false, vertical: true) + Text("Deja presionada una nota para mover su orden y deslízala hacía la izquierda para editarla o eliminarla") + .font(.headline) + .foregroundColor(Color.gray) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding() + Spacer() + } + + Spacer() + } + .padding(.top, 20) + .background(Color.white) + .cornerRadius(10) + .padding([.leading, .trailing, .bottom, .top], 10) + } + .listStyle(.inset) + }else{ + /* + HStack{ + Spacer() // Espacio superior + Text("Deja presionada una nota para reordenar y deslizala hacía la izquierda para editar o eliminar") + .foregroundColor(Color.white) + .fixedSize(horizontal: false, vertical: true) + Spacer() // Espacio inferior + } + .background(Color(.systemGray2)) + .frame(minHeight: 30) + */ + + ScrollViewReader { proxy in + List { + ForEach(Array(filteredNotes.enumerated()), id: \.element.id) { index, note in + + //Tarjeta paciente + NoteCardView(note: note) + .frame(minHeight: 150) + .padding([.top, .bottom], 5) + .swipeActions(edge: .trailing) { + // validar que el usuario sea admin para mostrar + if currentUser.isAdmin! { + Button { + //selectedNote = note + selectedNoteIndex = index + showDeleteNoteAlert = true + + } label: { + Label("Eliminar", systemImage: "trash") + } + .tint(.red) + .padding() + + Button { + selectedNoteToEdit = note + showEditNoteView = true + + } label: { + Label("Editar", systemImage: "pencil") + } + .tint(.blue) + .padding() + } + } + } + .onMove(perform: moveNote) + .onChange(of: selectedNoteIndex) { newIndex in + if let newIndex = newIndex { + let noteId = notes.notesList[newIndex].id + proxy.scrollTo(noteId, anchor: .top) + } + } + .padding(.top) + .alert(isPresented: $showDeleteNoteAlert) { + Alert(title: Text("Eliminar Nota"), + message: Text("¿Estás seguro de que quieres eliminar esta nota? Esta acción no se puede deshacer."), + primaryButton: .destructive(Text("Eliminar")) { + // Confirmar eliminación + if let index = self.selectedNoteIndex { + let noteId = notes.notesList[index].id + let patientId = notes.notesList[index].patientId + + notes.deleteData(noteId: noteId) { response in + if response == "OK" { + search = "" + notes.notesList.remove(atOffsets: IndexSet(integer: index)) + filteredNotes.remove(atOffsets: IndexSet(integer: index)) + Task{ + if let notesList = await notes.getDataById(patientId: patientId){ + DispatchQueue.main.async{ + self.notes.notesList = notesList.sorted { $0.order < $1.order } + self.filteredNotes = self.notes.notesList + } + } + } + } else { + // Aquí puedes manejar el error si lo deseas + print("Error al eliminar la nota: \(response)") + } + } + } + self.selectedNoteIndex = nil + //self.selectedNote = nil + }, + secondaryButton: .cancel { + // Cancelar eliminación + self.selectedNoteIndex = nil + //self.selectedNote = nil + } + ) + } + } + .listStyle(.inset) + } //ScrollViewReader + } + } + + } + .padding([.bottom, .trailing, .leading]) + + } + .sheet(isPresented: $showAddNoteView) { + AddNoteView(notes: notes, filteredNotes: $filteredNotes, search: $search, patient: patient) + } + .sheet(item: $selectedNoteToEdit){ note in + EditNoteView(notes: notes, filteredNotes: $filteredNotes, note: note) + } + .sheet(isPresented: $showEditPatientView){ + EditPatientView(patients: patients, patient: $patient) + } + .sheet(isPresented: $showCommunicatorMenu){ + CommunicatorMenuView(patient:patient) + } + } + .onAppear { + self.getPatientNotes(patientId: patient.id) + } + + Spacer() + } +} diff --git a/nuevoamanecer/Admin/View/Authentication/AuthView.swift b/nuevoamanecer/Admin/View/Authentication/AuthView.swift new file mode 100644 index 0000000..98607bc --- /dev/null +++ b/nuevoamanecer/Admin/View/Authentication/AuthView.swift @@ -0,0 +1,86 @@ +// +// AuthView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 19/05/23. +// + +import SwiftUI + +struct AuthView: View { + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var navPath: NavigationPathWrapper + + var body: some View { + GeometryReader { geometry in + VStack { + ZStack { + //Color.blue.opacity(0.9) + Color.blue + .ignoresSafeArea() + VStack { + Image("logo_white") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height:300) + .padding([.leading, .trailing, .top]) + + Text("Creando conexiones para facilitar la comunicación terapeuta-paciente.") + .font(.title) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + .padding(.bottom, 50) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + }.padding(.top, geometry.size.height * 0.1) + + } + .frame(height: geometry.size.height / 2) + + ZStack { + Color.white + .ignoresSafeArea() + VStack(spacing: 20) { + + Text("Accede a tu cuenta para ver a tus pacientes") + .font(.title) + .fontWeight(.regular) + .multilineTextAlignment(.center) + .padding(.horizontal) + .foregroundColor(.black) + .padding(.bottom) + + Button { + navPath.push(NavigationDestination(content: LoginView())) + } label: { + Text("Empezar") + .font(.title) + .foregroundColor(.white) + .padding() + .frame(maxWidth: 600) + .background(Color.green) + .cornerRadius(10) + .padding(.horizontal, 80) + } + /* + NavigationLink(destination: RegisterView(authViewModel: authViewModel)) { + Text("Registrarse") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.green) + .cornerRadius(10) + } + .padding(.horizontal, 80) + */ + } + .padding(.bottom, geometry.size.height * 0.1) + } + .frame(height: geometry.size.height / 2) + } + } + .accentColor(.white) // Cambia el color del título y los enlaces de navegación a blanco para un aspecto más profesional + } +} diff --git a/nuevoamanecer/Admin/View/Authentication/LoginView.swift b/nuevoamanecer/Admin/View/Authentication/LoginView.swift new file mode 100644 index 0000000..f2540e3 --- /dev/null +++ b/nuevoamanecer/Admin/View/Authentication/LoginView.swift @@ -0,0 +1,93 @@ +// +// LoginView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 17/05/23. +// + +import SwiftUI + +struct LoginView: View { + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var currentUser: UserWrapper + @EnvironmentObject var navPath: NavigationPathWrapper + + @State var email = "" + @State var password = "" + @State private var contraseñaIncorrecta: Bool = false + @State private var usuarioNoExiste: Bool = false + @State private var showAlert: Bool = false + @State private var showPassword: Bool = false + @FocusState private var inFocus: Field? + + var userVM: UserViewModel = UserViewModel() + + var body: some View { + GeometryReader { geometry in + VStack { + Text("Iniciar sesión") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(Color.blue) + + TextField("Correo electrónico", text: $email) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textContentType(.emailAddress) + .autocorrectionDisabled(true) + .autocapitalization(.none) // para evitar errores de correo electrónico en mayúsculas + + PasswordInputTextFieldView(password: $password) + + Button(action: { + Task { // Hacer login + let result: AuthActionResult = await authVM.loginAuthUser(email: email, password: password) + + if result.success && result.userId != nil { + userVM.getUser(userId: result.userId!) { error, user in + if error != nil || user == nil { + // No fue posible obtener la información del usuario que inicio sesión + _ = authVM.logout() + showAlert = true + } else { + currentUser.setUser(user: user!) + navPath.push(NavigationDestination(content: AdminView())) + } + } + } else { + // No fue posbile iniciar sesión correctamente + showAlert = true + } + } + }) { + Text("Iniciar sesión") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(email.isEmpty || password.isEmpty ? .gray : .blue) + .cornerRadius(10) + } + .disabled(email.isEmpty || password.isEmpty) + .padding(.horizontal) + .alert("Verifique su correo y contraseña", isPresented: $showAlert){ + Button(action: {showAlert = false}){ + Text("Ok") + } + } + message: { + Text("Puede que su correo o contraseña sean erróneos.") + } + } + .frame(maxWidth: min(500, geometry.size.width), alignment: .center) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding() + .background(Color.white) + .navigationTitle("") + } + .navigationViewStyle(.stack) + .accentColor(.blue) // Cambia el color del título y los enlaces de navegación a azul para un aspecto más profesional + } +} diff --git a/nuevoamanecer/Admin/View/Authentication/RegisterView.swift b/nuevoamanecer/Admin/View/Authentication/RegisterView.swift new file mode 100644 index 0000000..fb843e9 --- /dev/null +++ b/nuevoamanecer/Admin/View/Authentication/RegisterView.swift @@ -0,0 +1,214 @@ +// +// RegisterView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 17/05/23. +// + +import SwiftUI + +struct RegisterView: View { + enum Field : Hashable { + case plain + case secure + } + struct AlertItem: Identifiable { + var id = UUID() + var title: Text + var message: Text? + var dismissButton: Alert.Button? + } + + @Environment(\.dismiss) var dismiss + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var currentUser: UserWrapper + + @State var email = "" + @State var password = "" + @State var authPassword = "" + @State var name = "" + @State var isAdmin = false + @State var confirmpassword = "" + + @State private var alertItem: AlertItem? + @State private var showAuthAlert = true + + @State private var showPassword: Bool = false + @State private var showConfirmPassword: Bool = false + @FocusState private var inFocus: Field? + @FocusState private var inFocusConfirm: Field? + + var body: some View { + VStack { + Text("Registrarse") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: 600) + + TextField("Nombre", text: $name) + .padding() + .background(Color(.systemGray6)) + .textContentType(.username) + .autocapitalization(.none) + .autocorrectionDisabled(true) + .cornerRadius(10) + .padding(.bottom, 20) + .frame(maxWidth: 600) + + TextField("Correo electrónico", text: $email) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled(true) + .frame(maxWidth: 600) + + ZStack (alignment: .trailing) { + if showPassword { + TextField("Contraseña", text: $password) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .frame(maxWidth: 600) + .focused($inFocus, equals: .plain) + } else { + SecureField("Contraseña", text: $password) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .frame(maxWidth: 600) + .focused($inFocus, equals: .secure) + } + Button() { + showPassword.toggle() + inFocus = showPassword ? .plain : .secure + } label: { + Image(systemName: showPassword ? "eye" : "eye.slash") + .padding(.bottom) + .padding(.trailing) + } + } + + ZStack (alignment: .trailing) { + if showConfirmPassword { + TextField("Confirmar Contraseña", text: $confirmpassword) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .frame(maxWidth: 600) + .focused($inFocusConfirm, equals: .plain) + } else { + SecureField("Confirmar Contraseña", text: $confirmpassword) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(10) + .padding(.bottom, 20) + .textInputAutocapitalization(.never) + .keyboardType(.asciiCapable) + .autocorrectionDisabled(true) + .frame(maxWidth: 600) + .focused($inFocusConfirm, equals: .secure) + } + Button() { + showConfirmPassword.toggle() + inFocusConfirm = showConfirmPassword ? .plain : .secure + } label: { + Image(systemName: showConfirmPassword ? "eye" : "eye.slash") + .padding(.bottom) + .padding(.trailing) + } + } + + + Toggle(isOn: $isAdmin) { + Text("¿Es administrador?") + } + .padding(.bottom, 20) + + HStack { + //Cancelar + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + //Registrar + Button(action: { + if(password != confirmpassword){ + self.alertItem = AlertItem(title: Text("Confirme su contraseña"), message: Text("Por favor, verifique correctmente su contraseña."), dismissButton: .cancel(Text("OK"))) + } else if (!email.isValidEmail()){ + self.alertItem = AlertItem(title: Text("Correo Invalido"), message: Text("verifique que su correo sea correcto."), dismissButton: .cancel(Text("OK"))) + } else if (!PasswordValidator(password).isValidPassword()){ + self.alertItem = AlertItem(title: Text("Contraseña invalida"), message: Text("verifique que su contraseña tenga minimo 8 caracteres, un caracter en mayuscula, un caracter especial y un número."), dismissButton: .cancel(Text("OK"))) + } else { + Task { + _ = await authVM.createNewAuthAccount(email: email, password: password, currUserPassword: authPassword) + } + dismiss() + } + }) { + HStack { + Text("Registrar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(email.isEmpty || password.isEmpty || name.isEmpty ? .gray : .blue ) + .cornerRadius(10) + .foregroundColor(.white) + + } + .alert(item: $alertItem ) { alertItem in + Alert(title: alertItem.title, message: alertItem.message, dismissButton: alertItem.dismissButton) + } + // Añadir mensaje + } + .padding() + .padding(.horizontal, 70) + .alert("Escribe tu contraseña", isPresented: $showAuthAlert, actions: { + TextField("Contraseña", text: $authPassword) + .autocorrectionDisabled(true) + + Button("Okay", action: { + Task { + let result: AuthActionResult = await authVM.loginAuthUser(email: currentUser.email!, password: authPassword) + + if result.success { + // Exito + } else { + dismiss() + } + } + }) + Button("Cancel", role: .cancel, action: { dismiss() }) + }) + } +} diff --git a/nuevoamanecer/Admin/View/CommunicatorMenuView.swift b/nuevoamanecer/Admin/View/CommunicatorMenuView.swift new file mode 100644 index 0000000..702d096 --- /dev/null +++ b/nuevoamanecer/Admin/View/CommunicatorMenuView.swift @@ -0,0 +1,66 @@ +// +// CommunicatorMenuView.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 09/06/23. +// + +import SwiftUI + +struct CommunicatorMenuView: View { + var patient: Patient + + var body: some View { + NavigationView { + VStack { + VStack { + Text("Comunicador") + .font(.largeTitle) + .fontWeight(.bold) + + Text(patient.firstName) + .font(.title) + .fontWeight(.semibold) + .foregroundColor(Color.gray) + } + .padding() + + Button(action: { + // Acción para el comunicador + }) { + Text("Comunicador") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .cornerRadius(10) + } + .padding([.leading, .trailing], 20) + + Button(action: { + // Acción para el editor de comunicador + }) { + Text("Editor de comunicador") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.green) + .cornerRadius(10) + } + .padding([.leading, .trailing, .bottom], 20) + } + .navigationTitle("") + .navigationBarHidden(true) + } + } +} + + + +struct CommunicatorMenuView_Previews: PreviewProvider { + static var previews: some View { + CommunicatorMenuView(patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String](), identificador: "")) + } +} diff --git a/nuevoamanecer/Admin/View/ImagePicker_BACKUP.swift b/nuevoamanecer/Admin/View/ImagePicker_BACKUP.swift new file mode 100644 index 0000000..3b691ad --- /dev/null +++ b/nuevoamanecer/Admin/View/ImagePicker_BACKUP.swift @@ -0,0 +1,47 @@ +// +// ImagePicker.swift +// nuevoamanecer +// +// Created by Jose Arguelles Rios on 28/05/23. +// +/* +import Foundation +import SwiftUI + +struct ImagePicker : UIViewControllerRepresentable { + + @Binding var image: UIImage? + + private let controller = UIImagePickerController() + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + let parent: ImagePicker + + init(parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.image = info[.originalImage] as? UIImage + picker.dismiss(animated: true) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + } + + func makeUIViewController(context: Context) -> some UIViewController { + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + } +} +*/ diff --git a/nuevoamanecer/Admin/View/Layout/AdminNav.swift b/nuevoamanecer/Admin/View/Layout/AdminNav.swift new file mode 100644 index 0000000..ac7661a --- /dev/null +++ b/nuevoamanecer/Admin/View/Layout/AdminNav.swift @@ -0,0 +1,142 @@ +// +// AdminNav.swift +// nuevoamanecer +// +// Created by Gerardo Martínez on 05/06/23. +// + +import SwiftUI +import Kingfisher + +struct AdminNav: View { + @EnvironmentObject var authVM: AuthViewModel + @EnvironmentObject var currentUser: UserWrapper + @EnvironmentObject var navPath: NavigationPathWrapper + + @Binding var showAdminView: Bool + @Binding var showRegisterView: Bool + + @State private var upload_image: UIImage? + @State private var imageURL = URL(string: "") + @State private var storage = FirebaseAlmacenamiento() + + @State private var showLogoutAlert = false + + @EnvironmentObject var pathWrapper: NavigationPathWrapper + @EnvironmentObject var currentUserWrapper: UserWrapper + + var body: some View { + ZStack { + HStack { + Image("logo_name") + .resizable() + //.renderingMode(.template) + .aspectRatio(contentMode: .fit) + .frame(height: 30) + .padding() + + Spacer() + + ZStack{ + //No imagen + let userNames: [String] = currentUser.name!.splitAtWhitespaces() + let firstName: String = userNames.getElementSafely(index: 0) ?? " " + let lastName: String = userNames.getElementSafely(index: 0) ?? " " + + if currentUser.image == nil { + //ImagePlaceholderView(firstName: userNames.getElementSafely(index: 0) ?? "",lastName: userNames.getElementSafely(index: 0) ?? "", radius: 100, fontSize: 20) + Text(firstName.prefix(1) + lastName.prefix(1)) + .textCase(.uppercase) + .font(.system(size: 20)) + .fontWeight(.bold) + .frame(width: 50, height: 50) + .background(Color(.systemGray3)) + .foregroundColor(.white) + .clipShape(Circle()) + + } + //Imagen previamente subida + else{ + ZStack{ + KFImage(URL(string: currentUser.image!)) + .resizable() + .scaledToFill() + .frame(width: 50, height: 50) + .cornerRadius(128) + .padding(.horizontal, 20) + } + .padding(.horizontal, 20) + } + + Menu(" "){ + // boton para ir a la vista de perfil + Button(action: { showAdminView = true }) { + HStack { + Text("Mi perfil") + .font(.system(size: 16)) + } + } + + // solo usuarios administradores pueden crear otro usuario + if (currentUser.isAdmin! == true) { + // boton para ir a la vista de registro + Button(action: { navPath.push(NavigationDestination(content: UserManagement())) }) { + HStack { + Text("Administración de Usuarios") + .font(.system(size: 16)) + } + } + } + + // boton para cerrar sesion + Button(action: { + self.showLogoutAlert.toggle() + }) { + HStack { + Text("Cerrar sesión") + .font(.system(size: 16)) + Spacer() + + Image(systemName: "arrowshape.turn.up.left.fill") + .font(.system(size: 12)) + } + } + } + .frame(width: 50, height: 50) + } + .frame(width: 50, height: 50) + } + .padding(.horizontal, 50) + .alert(isPresented: $showLogoutAlert) { + Alert( + title: Text("Cerrar Sesión"), + message: Text("¿Estás seguro que quieres cerrar la sesión?"), + primaryButton: .destructive(Text("Cerrar sesión"), action: { + // logout + let result: AuthActionResult = authVM.logout() + + if result.success { + pathWrapper.returnToRoot() + } else { + // Error al cerrar sesión. + } + }), + secondaryButton: .cancel() + ) + } + + } + .frame(height: 50) + .foregroundColor(.white) + .padding(.top, 20) + + /* + .overlay( + Rectangle() + .fill(Color.gray) + .frame(height: 0.5) + .edgesIgnoringSafeArea(.horizontal), alignment: .bottom + ) + */ + } +} diff --git a/nuevoamanecer/Admin/ViewModel/AuthViewModel.swift b/nuevoamanecer/Admin/ViewModel/AuthViewModel.swift new file mode 100644 index 0000000..f097c82 --- /dev/null +++ b/nuevoamanecer/Admin/ViewModel/AuthViewModel.swift @@ -0,0 +1,171 @@ +// Importación de las librerías necesarias para la ejecución del código. +import SwiftUI +import Firebase +import FirebaseFirestore +import FirebaseAuth + +// Definición de la clase AuthViewModel que hereda de ObservableObject, esto permite la actualización de la vista en respuesta a los cambios de estado en esta clase. + +struct AuthActionResult { + let userId: String? + let success: Bool + let errorMessage: String? + + init(userId: String? = nil, success: Bool, errorMessage: String? = nil){ + self.userId = userId + self.success = success + self.errorMessage = errorMessage + } +} + +enum AuthUserProperty { + case email, password +} + +class AuthViewModel: ObservableObject { + private var auth: Auth = Auth.auth() + private var inActiveSession: Bool { + auth.currentUser != nil + } + + func loggedInAuthUserId() -> String? { + return auth.currentUser?.uid + } + + // Inicia la sesión de un usuario existente. + func loginAuthUser(email: String, password: String) async -> AuthActionResult { + do { + let signedInUser: FirebaseAuth.User = try await self.auth.signIn(withEmail: email, password: password).user + return AuthActionResult(userId: signedInUser.uid, success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + // Revalida al usuario actual, con el correo y contraseña proporcionados. + func reauthenticateAuthUser(email: String, password: String) async -> AuthActionResult { + if !inActiveSession { + return AuthActionResult(success: false, errorMessage: "No existe una sesión activa") + } + + do { + let credential: AuthCredential = EmailAuthProvider.credential(withEmail: email, password: password) + let reauthenticatedUser: FirebaseAuth.User = try await self.auth.currentUser!.reauthenticate(with: credential).user + return AuthActionResult(userId: reauthenticatedUser.uid, success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + // Crea un nuevo usuario. + func createNewAuthAccount(email: String, password: String, currUserPassword: String) async -> AuthActionResult { + if !inActiveSession { + return AuthActionResult(success: false, errorMessage: "No existe una sesión activa") + } + + let currUserEmail: String = auth.currentUser!.email! + + let reauthenticationResult: AuthActionResult = await self.reauthenticateAuthUser(email: currUserEmail, password: currUserPassword) + + if !reauthenticationResult.success { + return reauthenticationResult + } + + do { + let createdUser: FirebaseAuth.User = try await self.auth.createUser(withEmail: email, password: password).user + + _ = await self.loginAuthUser(email: currUserEmail, password: currUserPassword) + + return AuthActionResult(userId: createdUser.uid, success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + // Modifica una de las siguientes dos propiedades del usuario actual: email o contraseña. + func updateCurrentAuthUser(value: String, userProperty: AuthUserProperty, currUserPassword: String) async -> AuthActionResult { + if !inActiveSession { + return AuthActionResult(success: false, errorMessage: "No existe una sesión activa") + } + + let reauthenticationResult: AuthActionResult = await self.reauthenticateAuthUser(email: auth.currentUser!.email!, password: currUserPassword) + + if !reauthenticationResult.success { + return reauthenticationResult + } + + switch userProperty { + case .email: + return await self.updateEmail(email: value) + case .password: + return await self.updatePassword(password: value) + } + } + + // Modifica el correo del usuario actual. + private func updateEmail(email: String) async -> AuthActionResult { + if self.auth.currentUser == nil { + return AuthActionResult(success: false) + } + + do { + try await self.auth.currentUser!.updateEmail(to: email) + return AuthActionResult(success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + // Modifica la contraseña del usuario actual. + private func updatePassword(password: String) async -> AuthActionResult { + if self.auth.currentUser == nil { + return AuthActionResult(success: false, errorMessage: "No hay una sesión activa") + } + + do { + try await self.auth.currentUser!.updatePassword(to: password) + return AuthActionResult(success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + // Termina la sesión del usuario actual. + func logout() -> AuthActionResult { + do { + try self.auth.signOut() + return AuthActionResult(success: true) + } catch let error as NSError { + return AuthActionResult(success: false, errorMessage: handleAuthError(error)) + } + } + + private func handleAuthError(_ error: NSError) -> String { + switch error.code { + case AuthErrorCode.userNotFound.rawValue: + return "Usuario no encontrado" + case AuthErrorCode.wrongPassword.rawValue: + return "Contraseña incorrecta" + case AuthErrorCode.invalidEmail.rawValue: + return "Correo electrónico inválido" + case AuthErrorCode.emailAlreadyInUse.rawValue: + return "El correo electrónico ya está en uso" + case AuthErrorCode.userDisabled.rawValue: + return "La cuenta de usuario ha sido deshabilitada" + case AuthErrorCode.invalidCredential.rawValue: + return "Credenciales de autenticación inválidas" + case AuthErrorCode.operationNotAllowed.rawValue: + return "Operación no permitida" + case AuthErrorCode.accountExistsWithDifferentCredential.rawValue: + return "La cuenta ya existe con diferentes credenciales" + case AuthErrorCode.networkError.rawValue: + return "Error de red. Verifica tu conexión" + case AuthErrorCode.tooManyRequests.rawValue: + return "Demasiados intentos. Intenta de nuevo más tarde" + case AuthErrorCode.weakPassword.rawValue: + return "Contraseña débil" + default: + return "Error desconocido: \(error.localizedDescription)" + } + } +} diff --git a/nuevoamanecer/ViewModel/NotesViewModel.swift b/nuevoamanecer/Admin/ViewModel/NotesViewModel.swift similarity index 79% rename from nuevoamanecer/ViewModel/NotesViewModel.swift rename to nuevoamanecer/Admin/ViewModel/NotesViewModel.swift index b84bd75..48aa8a3 100644 --- a/nuevoamanecer/ViewModel/NotesViewModel.swift +++ b/nuevoamanecer/Admin/ViewModel/NotesViewModel.swift @@ -26,7 +26,7 @@ class NotesViewModel: ObservableObject{ let docRef = db.collection("Note").document() - docRef.setData(["id": note.id, "patientId": note.patientId, "order": notesList.count + 1, "title": note.title, "text": note.text, "date": note.date]) { err in + docRef.setData(["id": note.id, "patientId": note.patientId, "order": (notesList.count * -1) - 1, "title": note.title, "text": note.text, "date": note.date, "tag":note.tag]) { err in if let err = err { completion(err.localizedDescription) } else { @@ -59,11 +59,12 @@ class NotesViewModel: ObservableObject{ let order = data["order"] as? Int ?? 0 let title = data["title"] as? String ?? "" let text = data["text"] as? String ?? "" - let date = data["date"] as? Date ?? Date() + let date = (data["date"] as? Timestamp)?.dateValue() ?? Date() //let id = data["id"] as? String ?? UUID().uuidString + let tag = data["tag"] as? String ?? "" let id = document.documentID - let note = Note(id: id, patientId: patientId, order: order, title: title, text: text, date: date) + let note = Note(id: id, patientId: patientId, order: order, title: title, text: text, date: date, tag: tag) notes.append(note) } @@ -95,9 +96,10 @@ class NotesViewModel: ObservableObject{ let order = data["order"] as? Int ?? 0 let title = data["title"] as? String ?? "" let text = data["text"] as? String ?? "" - let date = data["date"] as? Date ?? Date() + let date = (data["date"] as? Timestamp)?.dateValue() ?? Date() + let tag = data["tag"] as? String ?? "" - let note = Note(id: id, patientId: patientId, order: order, title: title, text: text, date: date) + let note = Note(id: id, patientId: patientId, order: order, title: title, text: text, date: date, tag: tag) notes.append(note) } @@ -119,7 +121,8 @@ class NotesViewModel: ObservableObject{ "patientId": note.patientId, "order": note.order, "title": note.title, - "text": note.text + "text": note.text, + "tag": note.tag ]) { err in if let err = err { completion(err.localizedDescription) @@ -128,7 +131,7 @@ class NotesViewModel: ObservableObject{ } } } - + // Eliminación de nota func deleteData(noteId: String, completion: @escaping (String) -> Void) { let noteRef = db.collection("Note").document(noteId) @@ -144,19 +147,19 @@ class NotesViewModel: ObservableObject{ // Actualización de documento de paciente - private func updatePatientDocument(patient: Patient, note: Note, completion: @escaping (String) -> Void) { - let patientRef = db.collection("Patient").document(patient.id) - var patientNotes = patient.notes - patientNotes.append(note.id) + private func updatePatientDocument(patient: Patient, note: Note, completion: @escaping (String) -> Void) { + let patientRef = db.collection("Patient").document(patient.id) + var patientNotes = patient.notes + patientNotes.append(note.id) - patientRef.updateData([ - "notes": patientNotes - ]) { err in - if let err = err { - completion(err.localizedDescription) - } else { - completion("OK") - } + patientRef.updateData([ + "notes": patientNotes + ]) { err in + if let err = err { + completion(err.localizedDescription) + } else { + completion("OK") } } + } } diff --git a/nuevoamanecer/ViewModel/PatientsViewModel.swift b/nuevoamanecer/Admin/ViewModel/PatientsViewModel.swift similarity index 91% rename from nuevoamanecer/ViewModel/PatientsViewModel.swift rename to nuevoamanecer/Admin/ViewModel/PatientsViewModel.swift index 5c09421..b8d9bfe 100644 --- a/nuevoamanecer/ViewModel/PatientsViewModel.swift +++ b/nuevoamanecer/Admin/ViewModel/PatientsViewModel.swift @@ -52,7 +52,7 @@ class PatientsViewModel: ObservableObject{ let firstName = data["firstName"] as? String ?? "" let lastName = data["lastName"] as? String ?? "" - let birthDate = data["birthDate"] as? Date ?? Date() + let birthDate = (data["birthDate"] as? Timestamp)?.dateValue() ?? Date() let group = data["group"] as? String ?? "No asignado" let communicationStyle = data["communicationStyle"] as? String ?? "No asignado" let cognitiveLevel = data["cognitiveLevel"] as? String ?? "No asignado" @@ -60,12 +60,15 @@ class PatientsViewModel: ObservableObject{ let notes = data["notes"] as? [String] ?? [] //let id = data["id"] as? String ?? UUID().uuidString let id = document.documentID + let identificador = data["id"] as? String ?? "" - let patient = Patient(id: id, firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyle, cognitiveLevel: cognitiveLevel, image: image, notes: notes) + let patient = Patient(id: id, firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyle, cognitiveLevel: cognitiveLevel, image: image, notes: notes, identificador: identificador) patients.append(patient) } + patients.sort { $0.firstName.lowercased() < $1.firstName.lowercased() } + return patients } @@ -96,8 +99,9 @@ class PatientsViewModel: ObservableObject{ let cognitiveLevel = data["cognitiveLevel"] as? String ?? "No asignado" let image = data["image"] as? String ?? "" let notes = data["notes"] as? [String] ?? [] + let identificador = data["id"] as? String ?? "" - let patient = Patient(id: patientId, firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyle, cognitiveLevel: cognitiveLevel, image: image, notes: notes) + let patient = Patient(id: patientId, firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyle, cognitiveLevel: cognitiveLevel, image: image, notes: notes, identificador: identificador) return patient } @@ -128,19 +132,6 @@ class PatientsViewModel: ObservableObject{ } } - /* - // Eliminación de un paciente - func deleteData(patientId: String, completion: @escaping (String) -> Void) { - db.collection("Patient").document(patientId).delete() { err in - if let err = err { - completion(err.localizedDescription) - } else { - completion("OK") - } - } - } - */ - func deleteData(patient: Patient, completion: @escaping (String) -> Void) async { do { let patientRef = db.collection("Patient").document(patient.id) @@ -161,6 +152,4 @@ class PatientsViewModel: ObservableObject{ completion("Failed") } } - - } diff --git a/nuevoamanecer/Admin/ViewModel/VoiceSettingViewModel.swift b/nuevoamanecer/Admin/ViewModel/VoiceSettingViewModel.swift new file mode 100644 index 0000000..866fe1c --- /dev/null +++ b/nuevoamanecer/Admin/ViewModel/VoiceSettingViewModel.swift @@ -0,0 +1,54 @@ +// +// VoiceConfigurationViewModel.swift +// nuevoamanecer +// +// Created by emilio on 05/10/23. +// + +import Foundation +import Firebase +import FirebaseFirestore + +class VoiceSettingViewModel { + var voiceSettingCollection: CollectionReference = Firestore.firestore().collection("voiceSettings") + + func getVoiceSetting(patientId: String, completition: @escaping (Error?, VoiceSetting?)->Void) -> Void { + self.voiceSettingCollection.whereField("patientId", isEqualTo: patientId).getDocuments { querySnapshot, error in + if error != nil || querySnapshot == nil { + completition(error, nil) + } else { + completition(nil, try? querySnapshot!.documents.first?.data(as: VoiceSetting.self)) + } + } + } + + func editVoiceSetting(voiceSettingId: String, voiceSetting: VoiceSetting, completition: @escaping (Error?)->Void) -> Void { + do { + try voiceSettingCollection.document(voiceSettingId).setData(from: voiceSetting) { error in + if error != nil { + completition(error) + } else { + completition(nil) + } + } + } catch let error { + completition(error) + } + } + + func createVoiceSetting(voiceSetting: VoiceSetting = VoiceSetting.defaultVoiceSetting(), completition: @escaping (Error?, String?)->Void) { + var docRef: DocumentReference? = nil + + do { + docRef = try voiceSettingCollection.addDocument(from: voiceSetting) { error in + if error != nil { + completition(error, nil) + } else { + completition(nil, docRef?.documentID) + } + } + } catch let error { + completition(error, nil) + } + } +} diff --git a/nuevoamanecer/Assets.xcassets/.DS_Store b/nuevoamanecer/Assets.xcassets/.DS_Store new file mode 100644 index 0000000..4871f76 Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/.DS_Store differ diff --git a/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Contents.json b/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3..53b8799 100644 --- a/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "Mind (1).png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Mind (1).png b/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Mind (1).png new file mode 100644 index 0000000..c617b32 Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/AppIcon.appiconset/Mind (1).png differ diff --git a/nuevoamanecer/Assets.xcassets/logo_blue.imageset/1.png b/nuevoamanecer/Assets.xcassets/logo_blue.imageset/1.png new file mode 100644 index 0000000..821b5ed Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/logo_blue.imageset/1.png differ diff --git a/nuevoamanecer/Assets.xcassets/logo_blue.imageset/Contents.json b/nuevoamanecer/Assets.xcassets/logo_blue.imageset/Contents.json new file mode 100644 index 0000000..38ee28b --- /dev/null +++ b/nuevoamanecer/Assets.xcassets/logo_blue.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/2.png b/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/2.png new file mode 100644 index 0000000..5aed1f8 Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/2.png differ diff --git a/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/Contents.json b/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/Contents.json new file mode 100644 index 0000000..c6348dd --- /dev/null +++ b/nuevoamanecer/Assets.xcassets/logo_blue_alt.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nuevoamanecer/Assets.xcassets/logo_name.imageset/Contents.json b/nuevoamanecer/Assets.xcassets/logo_name.imageset/Contents.json new file mode 100644 index 0000000..371b115 --- /dev/null +++ b/nuevoamanecer/Assets.xcassets/logo_name.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo_name.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nuevoamanecer/Assets.xcassets/logo_name.imageset/logo_name.png b/nuevoamanecer/Assets.xcassets/logo_name.imageset/logo_name.png new file mode 100644 index 0000000..898671b Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/logo_name.imageset/logo_name.png differ diff --git a/nuevoamanecer/Assets.xcassets/logo_white.imageset/5.png b/nuevoamanecer/Assets.xcassets/logo_white.imageset/5.png new file mode 100644 index 0000000..2aafa56 Binary files /dev/null and b/nuevoamanecer/Assets.xcassets/logo_white.imageset/5.png differ diff --git a/nuevoamanecer/Assets.xcassets/logo_white.imageset/Contents.json b/nuevoamanecer/Assets.xcassets/logo_white.imageset/Contents.json new file mode 100644 index 0000000..90ec8dd --- /dev/null +++ b/nuevoamanecer/Assets.xcassets/logo_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/nuevoamanecer/ContentView.swift b/nuevoamanecer/ContentView.swift index 2ff1bac..5d0794a 100644 --- a/nuevoamanecer/ContentView.swift +++ b/nuevoamanecer/ContentView.swift @@ -8,32 +8,54 @@ import SwiftUI struct ContentView: View { - //@ObservedObject var authViewModel = AuthViewModel() + @StateObject var authVM: AuthViewModel = AuthViewModel() + @StateObject var currentUser: UserWrapper = UserWrapper() + @StateObject var navPath = NavigationPathWrapper() // Contiene una instancia de NavigationPath, es proveida como variable de ambiente. + var userVM: UserViewModel = UserViewModel() var body: some View { - VStack { - - /* - if let user = authViewModel.user { - if user.isAdmin { - HomeView(authViewModel: authViewModel) // Si el usuario es un admin, muestra la vista de búsqueda - } else { - //HomeView(authViewModel: authViewModel) // Si el usuario no es un admin, muestra la vista de perfil del niño - } - } else { - AuthView(authViewModel: authViewModel) // Si no hay usuario, muestra la vista de inicio de sesión - } + NavigationStack(path: $navPath.path) { + AuthView() + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } + .navigationDestination(for: NavigationDestination.self) { destination in + destination.content + } } + .environmentObject(authVM) + .environmentObject(currentUser) + .environmentObject(navPath) .onAppear { - Task.init { - await authViewModel.getCurrentUser() + if authVM.loggedInAuthUserId() != nil { + userVM.getUser(userId: authVM.loggedInAuthUserId()!) { error, user in + if error != nil || user == nil { + // Error al obtener usuario + } else { + currentUser.setUser(user: user!) + navPath.push(NavigationDestination(content: AdminView())) + } + } } - */ - - AdminView() } } } + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/nuevoamanecer/Data/FirebaseStorage_BACKUP.swift b/nuevoamanecer/Data/FirebaseStorage_BACKUP.swift new file mode 100644 index 0000000..2e999a3 --- /dev/null +++ b/nuevoamanecer/Data/FirebaseStorage_BACKUP.swift @@ -0,0 +1,53 @@ +// +// FirebaseStorage.swift +// nuevoamanecer +// +// Created by Jose Arguelles Rios on 28/05/23. +// + +/* +import Foundation +import FirebaseStorage +import UIKit + +class FirebaseAlmacenamiento { + func uploadImage(image:UIImage, name:String, completion: @escaping (URL?) -> Void) async { + if let imageData = image.jpegData(compressionQuality: 0.5) { + let storage = Storage.storage() + do { + let storedImage = try await storage.reference().child(name + ".jpg").putDataAsync(imageData, metadata:nil) + + let storageRef = Storage.storage().reference(withPath: name + ".jpg") + + storageRef.downloadURL { (url, error) in + if error != nil { + print((error?.localizedDescription)!) + completion (nil) + } else { + completion (url) + } + } + } + catch { + print("No se pudo subir la imagen") + } + } else { + print("No se pudo subir la imagen") + } + } + func deleteFile(name: String) { + //Crear referencia + let referencia = Storage.storage().reference().child(name + ".jpg") + //Borrar archivo + referencia.delete { error in + if let error = error { + // Uh-oh, an error occurred! + print(error.localizedDescription) + } else { + // File deleted successfully + print("Archivo borrado con exito") + } + } + } +} +*/ diff --git a/nuevoamanecer/DismissView.swift b/nuevoamanecer/DismissView.swift deleted file mode 100644 index fc4b062..0000000 --- a/nuevoamanecer/DismissView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// DismissView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 16/05/23. -// - -import SwiftUI - -struct DismissView: View { - @Environment(\.dismiss) var dismiss - var body: some View { - HStack{ - - Spacer() - Button("Cerrar"){ - dismiss() - } - .tint(.black) - .padding(.trailing) - } - .buttonStyle(.bordered) - } -} - -struct DismissView_Previews: PreviewProvider { - static var previews: some View { - DismissView() - } -} diff --git a/nuevoamanecer/HomeView.swift b/nuevoamanecer/HomeView.swift deleted file mode 100644 index 7dee60c..0000000 --- a/nuevoamanecer/HomeView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// HomeView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 16/05/23. -// -/* -import SwiftUI - -struct HomeView: View { - // @ObservedObject var authViewModel: AuthViewModel - var body: some View { - NavigationView{ - VStack{ - Text("Bienvenido") - .padding(.top, 32) - Spacer() - - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Home") - .toolbar{ - Button("Logout"){ - //authViewModel.signOut() - } - } - } - } -} - -struct HomeView_Previews: PreviewProvider { - static var previews: some View { - HomeView(authViewModel: AuthViewModel()) - } -} -*/ diff --git a/nuevoamanecer/ImageHandling/FirebaseStorage.swift b/nuevoamanecer/ImageHandling/FirebaseStorage.swift new file mode 100644 index 0000000..1f4113c --- /dev/null +++ b/nuevoamanecer/ImageHandling/FirebaseStorage.swift @@ -0,0 +1,92 @@ +// +// FirebaseStorage.swift +// nuevoamanecer +// +// Created by Jose Arguelles Rios on 28/05/23. +// + +import Foundation +import FirebaseStorage +import UIKit + +class FirebaseAlmacenamiento { + func uploadImage(image:UIImage, name:String, completion: @escaping (URL?) -> Void) async { + if let imageData = image.jpegData(compressionQuality: 0.5) { + let storage = Storage.storage() + do { + let storedImage = try await storage.reference().child(name + ".jpg").putDataAsync(imageData, metadata:nil) + + let storageRef = Storage.storage().reference(withPath: name + ".jpg") + + storageRef.downloadURL { (url, error) in + if error != nil { + print((error?.localizedDescription)!) + completion (nil) + } else { + completion (url) + } + } + } + catch { + print("No se pudo subir la imagen") + } + } else { + print("No se pudo subir la imagen") + } + } + + func uploadImage(image: UIImage, name: String) async -> URL? { + if let imageData: Data = image.jpegData(compressionQuality: 0.5){ + let storageRef: StorageReference = Storage.storage().reference().child(name + ".jpg") + + do { + _ = try await storageRef.putDataAsync(imageData, metadata: nil) + let downloadUrl: URL = try await storageRef.downloadURL() + return downloadUrl + } catch { + return nil + } + } else { + return nil + } + } + + func loadImageFromFirebase(name:String) { + let storageRef = Storage.storage().reference(withPath: name) + + storageRef.downloadURL { (url, error) in + if error != nil { + print((error?.localizedDescription)!) + return + } + //self.imageURL = url! + } + } + + + func deleteFile(name: String) { + //Crear referencia + let referencia = Storage.storage().reference().child(name + ".jpg") + //Borrar archivo + referencia.delete { error in + if let error = error { + // Uh-oh, an error occurred! + print(error.localizedDescription) + } else { + // File deleted successfully + print("Archivo borrado con exito") + } + } + } + + func deleteImage(donwloadUrl: String) async -> Bool { + let storageRef: StorageReference = Storage.storage().reference(forURL: donwloadUrl) + + do { + try await storageRef.delete() + return true + } catch { + return false + } + } +} diff --git a/nuevoamanecer/ImageHandling/ImagePicker.swift b/nuevoamanecer/ImageHandling/ImagePicker.swift new file mode 100644 index 0000000..12c656d --- /dev/null +++ b/nuevoamanecer/ImageHandling/ImagePicker.swift @@ -0,0 +1,46 @@ +// +// ImagePicker.swift +// nuevoamanecer +// +// Created by Jose Arguelles Rios on 28/05/23. +// + +import Foundation +import SwiftUI + +struct ImagePicker : UIViewControllerRepresentable { + + @Binding var image: UIImage? + + private let controller = UIImagePickerController() + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + let parent: ImagePicker + + init(parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.image = info[.originalImage] as? UIImage + picker.dismiss(animated: true) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + } + + func makeUIViewController(context: Context) -> some UIViewController { + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + } +} diff --git a/nuevoamanecer/Model/User.swift b/nuevoamanecer/Model/User.swift deleted file mode 100644 index d7f7e5a..0000000 --- a/nuevoamanecer/Model/User.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// User.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 17/05/23. -// - -import Foundation - -struct User { - let id: String! - let name: String - let email: String - let isAdmin: Bool -} diff --git a/nuevoamanecer/PictogramSection/.DS_Store b/nuevoamanecer/PictogramSection/.DS_Store new file mode 100644 index 0000000..65a955b Binary files /dev/null and b/nuevoamanecer/PictogramSection/.DS_Store differ diff --git a/nuevoamanecer/PictogramSection/Album/.DS_Store b/nuevoamanecer/PictogramSection/Album/.DS_Store new file mode 100644 index 0000000..5b03de0 Binary files /dev/null and b/nuevoamanecer/PictogramSection/Album/.DS_Store differ diff --git a/nuevoamanecer/PictogramSection/Album/MainViews/Album.swift b/nuevoamanecer/PictogramSection/Album/MainViews/Album.swift new file mode 100644 index 0000000..49af91f --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/MainViews/Album.swift @@ -0,0 +1,139 @@ +// +// Album.swift +// nuevoamanecer +// +// Created by emilio on 07/06/23. +// + +import SwiftUI + +struct Album: View { + var patientId: String + + @StateObject var pageVM: PageViewModel + @StateObject var boardCache: BoardCache + + @State var searchText: String = "" + @State var sortType: SortType = .byLastOpenedAt + @State var ascending: Bool = false + + @State var showingOptionsOf: String? = nil // Id de la página del álbum + @State var pageToDelete: String? = nil // Id de la página del álbum + @State var isDeleting: Bool = false + + init(patientId: String){ + self.patientId = patientId + _pageVM = StateObject(wrappedValue: PageViewModel(collectionPath: "Patient/\(patientId)/pages")) + + let basePictoCollPath: String = "basePictograms" + let patientPictoCollPath: String = "User/\(patientId)/pictograms" + let baseCatCollPath: String = "baseCategories" + let patientCatCollPath: String = "User/\(patientId)/categories" + + _boardCache = StateObject(wrappedValue: BoardCache(basePictoCollPath: basePictoCollPath, patientPictoCollPath: patientPictoCollPath, baseCatCollPath: baseCatCollPath, patientCatCollPath: patientCatCollPath)) + } + + var body: some View { + GeometryReader {geo in + NavigationStack { + VStack { + HStack(spacing: 15) { + SearchBarView(searchText: $searchText, placeholder: "Buscar Hoja", searchBarWidth: geo.size.width * 0.2) + + Text("Ordenar por:") + + Picker(selection: $sortType) { + ForEach(SortType.allCases){ sortTypeValue in + Text(sortTypeValue.rawValue) + } + } label: { + Text(sortType.rawValue) + } + + Button { + ascending.toggle() + } label: { + Image(systemName: ascending ? "arrow.up" : "arrow.down") + } + + Spacer() + } + .padding(.vertical, 20) + .padding(.horizontal, 70) + + Divider() + + let gridWidth: CGFloat = (geo.size.width * 0.8) + let thumbnailWidth: CGFloat = 250 + let thumbnailHeight: CGFloat = 200 + let thumbnailHorSpacing: CGFloat = 35 + let thumbnailVerSpacing: CGFloat = 50 + let numThumbnailsPerRow: Int = Int((gridWidth + thumbnailHorSpacing) / (thumbnailWidth + thumbnailHorSpacing)) + let gridColumns: [GridItem] = Array(repeating: GridItem(.flexible(), spacing: thumbnailHorSpacing, alignment: .center), count: numThumbnailsPerRow == 0 ? 1 : numThumbnailsPerRow) + + let pagesInScreen: [PageModel] = searchText.isEmpty ? pageVM.getPages(sortType: sortType, ascending: ascending) : pageVM.getPages(sortType: sortType, ascending: ascending, textFilter: searchText) + + ScrollView { + LazyVGrid(columns: gridColumns, spacing: thumbnailVerSpacing) { + NavigationLink { + PageEdit(patientId: patientId, pageVM: pageVM, pageModel: PageModel.defaultPage(), boardCache: boardCache) + } label: { + VStack { + Image(systemName: "plus") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: thumbnailWidth * 0.5) + .foregroundColor(.blue) + + Text("Nueva Página") + .bold() + .foregroundColor(.blue) + } + } + .frame(width: thumbnailWidth, height: thumbnailHeight) + + ForEach(pagesInScreen, id: \.id){ pageModel in + NavigationLink { + PageDisplay(patientId: patientId, pageVM: pageVM, pageModel: pageModel, boardCache: boardCache) + } label: { + PageThumbnail(pageModel: pageModel, boardCache: boardCache) + .id(UUID()) // ??? + .overlay(alignment: .top){ + let performWhenDeleted: () -> Void = { + pageToDelete = pageModel.id! + isDeleting = true + } + + PageOptionsView(pageId: pageModel.id!, showingOptionsOf: $showingOptionsOf, patientId: patientId, pageVM: pageVM, pageModel: pageModel, boardCache: boardCache, performWhenDeleted: performWhenDeleted) + .frame(width: thumbnailWidth * 0.75, height: thumbnailHeight * 0.9) + } + } + .frame(width: thumbnailWidth, height: thumbnailHeight) + } + } + } + .padding(.vertical, 30) + .frame(width: gridWidth) + } + } + .onTapGesture { + showingOptionsOf = nil + } + } + .onAppear { + showingOptionsOf = nil + } + .customConfirmAlert(title: "Eliminar página", message: "Presione confirmar para eliminar permanentemente la página.", isPresented: $isDeleting){ + if pageToDelete != nil { + pageVM.removePage(pageId: pageToDelete!){ error in + if error != nil { + // Error en la eliminación + } else { + // Eliminación exitosa + isDeleting = false + } + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/MainViews/PageDisplay.swift b/nuevoamanecer/PictogramSection/Album/MainViews/PageDisplay.swift new file mode 100644 index 0000000..145f595 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/MainViews/PageDisplay.swift @@ -0,0 +1,68 @@ +// +// PageView.swift +// nuevoamanecer +// +// Created by emilio on 20/06/23. +// + +import SwiftUI + +struct PageDisplay: View { + var patientId: String + @ObservedObject var pageVM: PageViewModel + @State var pageModel: PageModel + @ObservedObject var boardCache: BoardCache + + @State var isConfiguringVoice: Bool = false + @State var soundOn: Bool = true + @State var voiceGender: String = "Femenina" + @State var talkingSpeed: String = "Normal" + + @EnvironmentObject var appLock: AppLock + + @State var voiceSetting = VoiceSetting.defaultVoiceSetting() + + var body: some View { + GeometryReader { geo in + VStack { + HStack(spacing: 15) { + Text(pageModel.name) + .font(.system(size: 30, weight: .bold)) + + Spacer() + + ButtonWithImageView(text: soundOn ? "Desactivar Sonido" : "Activar Sonido", systemNameImage: soundOn ? "speaker.slash" : "speaker", isDisabled: appLock.isLocked){ + soundOn.toggle() + } + + ButtonView(text: "Configuración Voz", color: .blue, isDisabled: appLock.isLocked) { + //modal con opciones de velocidad de pronunciacion y genero de voz + isConfiguringVoice = true + } + .font(.headline) + .sheet(isPresented: $isConfiguringVoice) { + VoiceSettingView(voiceSetting: $voiceSetting) + } + + LockView() + } + .padding(.vertical, 20) + .padding(.horizontal, 70) + + Divider() + + PageBoardView(pageModel: $pageModel, boardCache: boardCache, pictoBaseWidth: 200, pictoBaseHeight: 200, isEditing: false, soundOn: soundOn, voiceGender: voiceGender, talkingSpeed: talkingSpeed) + } + } + .onAppear { + pageVM.updatePageLastOpenedAt(pageId: pageModel.id!) { error in + if error != nil { + // Error + } else { + // Exito + } + } + } + .navigationBarBackButtonHidden(appLock.isLocked) + } +} diff --git a/nuevoamanecer/PictogramSection/Album/MainViews/PageEdit.swift b/nuevoamanecer/PictogramSection/Album/MainViews/PageEdit.swift new file mode 100644 index 0000000..9f0ad1b --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/MainViews/PageEdit.swift @@ -0,0 +1,96 @@ +// +// PageEditView.swift +// nuevoamanecer +// +// Created by emilio on 20/06/23. +// + +import SwiftUI + +struct PageEdit: View { + var patientId: String + @ObservedObject var pageVM: PageViewModel + @State var pageModel: PageModel + @State var pageModelCapture: PageModel + @ObservedObject var boardCache: BoardCache + + @State var pickingPictograms: Bool = false + + @State var pageId: String? + + @Environment(\.dismiss) private var dismiss + + init(patientId: String, pageVM: PageViewModel, pageModel: PageModel, boardCache: BoardCache){ + self.patientId = patientId + self.pageVM = pageVM + _pageModel = State(initialValue: pageModel) + _pageModelCapture = State(initialValue: pageModel) + self.boardCache = boardCache + _pageId = State(initialValue: pageModel.id) + } + + var body: some View { + GeometryReader { geo in + VStack { + HStack(spacing: 15) { + TextFieldView(fieldWidth: geo.size.width * 0.3, placeHolder: pageModelCapture.name, inputText: $pageModel.name, maxCharLength: 25) // 25: número máximo de caracteres que el nombre de la página puede tener. + + Spacer() + + ButtonWithImageView(text: "Agregar pictogramas", systemNameImage: "plus") { + pickingPictograms = true + } + + let pageHasChanged: Bool = pageModelCapture != pageModel + ButtonWithImageView(text: pageHasChanged || pageId == nil ? "Guardar" : "Guardado", systemNameImage: pageHasChanged || pageId == nil ? "square.and.arrow.down" : "checkmark", isDisabled: !(pageHasChanged || pageId == nil)) { + if pageId == nil { + pageVM.addPage(pageModel: pageModel) { error, docId in + if error != nil { + // Error + } else if docId == nil { + dismiss() + } else { + pageId = docId! + pageModelCapture = pageModel + } + } + } else { + pageVM.editPage(pageId: pageId!, pageModel: pageModel){ error in + if error != nil { + // Error + } else { + pageModelCapture = pageModel + } + } + } + } + } + .padding(.vertical, 20) + .padding(.horizontal, 70) + + Divider() + + PageBoardView(pageModel: $pageModel, boardCache: boardCache, pictoBaseWidth: 200, pictoBaseHeight: 200, isEditing: true) + } + .onChange(of: pickingPictograms) { _ in + Task { + await boardCache.populateBoard(pictosInPage: pageModel.pictogramsInPage) + } + } + } + .fullScreenCover(isPresented: $pickingPictograms) { + DoublePictogramPickerView(pageModel: $pageModel, isPresented: $pickingPictograms, pictoCollectionPath1: "basePictograms", catCollectionPath1: "baseCategories", pictoCollectionPath2: "User/\(patientId)/pictograms", catCollectionPath2: "User/\(patientId)/categories") + } + .onAppear { + if pageId != nil { + pageVM.updatePageLastOpenedAt(pageId: pageModel.id!) { error in + if error != nil { + // Error + } else { + // Exito + } + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/MainViews/PageThumbnail.swift b/nuevoamanecer/PictogramSection/Album/MainViews/PageThumbnail.swift new file mode 100644 index 0000000..a513975 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/MainViews/PageThumbnail.swift @@ -0,0 +1,33 @@ +// +// PageThumbnailView.swift +// nuevoamanecer +// +// Created by emilio on 20/06/23. +// + +import SwiftUI + +struct PageThumbnail: View { + @State var pageModel: PageModel + @ObservedObject var boardCache: BoardCache + + init(pageModel: PageModel, boardCache: BoardCache){ + _pageModel = State(wrappedValue: pageModel) + self.boardCache = boardCache + } + + var body: some View { + GeometryReader { geo in + VStack(spacing: 10) { + PageBoardView(pageModel: $pageModel, boardCache: boardCache, pictoBaseWidth: 40, pictoBaseHeight: 40, isEditing: false) + .border(.gray) + + Text("\(pageModel.name)") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.black) + } + .padding(.vertical, 10) + .frame(width: geo.size.width, height: geo.size.height) + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Models/PageModel.swift b/nuevoamanecer/PictogramSection/Album/Models/PageModel.swift new file mode 100644 index 0000000..a5c88d2 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Models/PageModel.swift @@ -0,0 +1,56 @@ +// +// PageModel.swift +// nuevoamanecer +// +// Created by emilio on 07/06/23. +// + +import Foundation +import FirebaseFirestoreSwift + +struct PageModel: Identifiable, Codable, Equatable { + @DocumentID var id: String? + var name: String + var createdAt: Date + var lastOpenedAt: Date + var pictogramsInPage: [PictogramInPage] + + static func ==(lhs: PageModel, rhs: PageModel) -> Bool { + if lhs.pictogramsInPage.count != rhs.pictogramsInPage.count { + return false + } + + var result: Bool = true + result = result && lhs.id == rhs.id + result = result && lhs.name == rhs.name + result = result && lhs.createdAt == rhs.createdAt + result = result && lhs.lastOpenedAt == rhs.lastOpenedAt + result = result && lhs.pictogramsInPage == rhs.pictogramsInPage + return result + } + + static func defaultPage(name: String = "Hoja sin nombre") -> PageModel { + return PageModel(name: name, + createdAt: Date.now, + lastOpenedAt: Date.now, + pictogramsInPage: []) + } +} + +struct PictogramInPage: Codable, Equatable { + var pictoId: String + var isBasePicto: Bool + var xOffset: Double = 0 // -1 ... 1 + var yOffset: Double = 0 // -1 ... 1 + var scale: Double = 1 // 0.5 ... 3 + + static func ==(lhs: PictogramInPage, rhs: PictogramInPage) -> Bool { + var result: Bool = true + result = result && lhs.pictoId == rhs.pictoId + result = result && lhs.isBasePicto == rhs.isBasePicto + result = result && lhs.xOffset == rhs.xOffset + result = result && lhs.yOffset == rhs.yOffset + result = result && lhs.scale == rhs.scale + return result + } +} diff --git a/nuevoamanecer/PictogramSection/Album/ViewModels/BoardCache.swift b/nuevoamanecer/PictogramSection/Album/ViewModels/BoardCache.swift new file mode 100644 index 0000000..c2745e2 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/ViewModels/BoardCache.swift @@ -0,0 +1,89 @@ +// +// AlbumCache.swift +// nuevoamanecer +// +// Created by emilio on 27/07/23. +// + +import Foundation +import FirebaseFirestore +import FirebaseFirestoreSwift + +class BoardCache: ObservableObject { + @Published private var cachedPictos: [String:PictogramModel] = [:] + @Published private var cachedCats: [String:CategoryModel] = [:] + private var basePictoCollection: CollectionReference + private var patientPictoCollection: CollectionReference + private var baseCatCollection: CollectionReference + private var patientCatCollection: CollectionReference + + init(basePictoCollPath: String, patientPictoCollPath: String, baseCatCollPath: String, patientCatCollPath: String){ + self.basePictoCollection = Firestore.firestore().collection(basePictoCollPath) + self.patientPictoCollection = Firestore.firestore().collection(patientPictoCollPath) + + self.baseCatCollection = Firestore.firestore().collection(baseCatCollPath) + self.patientCatCollection = Firestore.firestore().collection(patientCatCollPath) + } + + func getPicto(pictoId: String) -> PictogramModel? { + return self.cachedPictos[pictoId] + } + + func getCat(catId: String) -> CategoryModel? { + return self.cachedCats[catId] + } + + func addPictoToCache(pictoId: String, isBasePicto: Bool) async -> PictogramModel? { + if self.cachedPictos[pictoId] == nil { + if isBasePicto { + if let pictoModel: PictogramModel = try? await basePictoCollection.document(pictoId).getDocument(as: PictogramModel.self){ + DispatchQueue.main.async { [self] in + self.cachedPictos[pictoId] = pictoModel + } + } + } else { + if let pictoModel: PictogramModel = try? await patientPictoCollection.document(pictoId).getDocument(as: PictogramModel.self) { + DispatchQueue.main.async { [self] in + self.cachedPictos[pictoId] = pictoModel + } + } + } + } + + return self.cachedPictos[pictoId] + } + + func addCatToCache(catId: String, isBaseCat: Bool) async -> CategoryModel? { + if self.cachedCats[catId] == nil { + if isBaseCat { + if let catModel: CategoryModel = try? await baseCatCollection.document(catId).getDocument(as: CategoryModel.self) { + DispatchQueue.main.async { [self] in + self.cachedCats[catId] = catModel + } + } + } else { + if let catModel: CategoryModel = try? await patientCatCollection.document(catId).getDocument(as: CategoryModel.self) { + DispatchQueue.main.async { [self] in + self.cachedCats[catId] = catModel + } + } + } + } + + return self.cachedCats[catId] + } + + func populateBoard(pictosInPage: [PictogramInPage]) async -> Void { + var addedPictos: [PictogramModel?] = [] + for pictoInPage in pictosInPage { + let pictoModel: PictogramModel? = await self.addPictoToCache(pictoId: pictoInPage.pictoId, isBasePicto: pictoInPage.isBasePicto) + addedPictos.append(pictoModel) + } + + for i in 0.. Void { + switch changeType { + case .added, .modified: + self.pages[pageModel.id!] = pageModel + self.objectWillChange.send() + case .removed: + self.pages.removeValue(forKey: pageModel.id!) + } + } + + func getPage(pageId: String) -> PageModel? { + return self.pages[pageId] + } + + func getPages(sortType: SortType, ascending: Bool) -> [PageModel] { + switch sortType { + case .byName: + return Array(self.pages.values).sorted(by: ascending ? {$0.name < $1.name} : {$0.name > $1.name}) + case .byCreatedAt: + return Array(self.pages.values).sorted(by: ascending ? {$0.createdAt < $1.createdAt} : {$0.createdAt > $1.createdAt}) + case .byLastOpenedAt: + return Array(self.pages.values).sorted(by: ascending ? {$0.lastOpenedAt < $1.lastOpenedAt} : {$0.lastOpenedAt > $1.lastOpenedAt}) + } + } + + func getPages(sortType: SortType, ascending: Bool, textFilter: String) -> [PageModel] { + let textFilterLowerd: String = textFilter.lowercased().replacingOccurrences(of: " " , with: "") + + return self.getPages(sortType: sortType, ascending: ascending).filter { + $0.name.lowercased().replacingOccurrences(of: " " , with: "").contains(textFilterLowerd) + } + } + + func addPage(pageModel: PageModel, completition: @escaping (Error?, String?)->Void) -> Void { + var docRef: DocumentReference? = nil + + do { + docRef = try self.pageCollection.addDocument(from: pageModel) { error in + completition(error, docRef?.documentID) + } + } catch let error { + completition(error, nil) + } + } + + func removePage(pageId: String, completition: @escaping (Error?)->Void) -> Void { + self.pageCollection.document(pageId).delete(){ error in + completition(error) + } + } + + func editPage(pageId: String, pageModel: PageModel, completition: @escaping (Error?)->Void) -> Void { + do { + try self.pageCollection.document(pageId).setData(from: pageModel){ error in + completition(error) + } + } catch let error { + completition(error) + } + } + + func updatePageLastOpenedAt(pageId: String, completition: @escaping (Error?)->Void) -> Void { + pageCollection.document(pageId).updateData(["lastOpenedAt": FieldValue.serverTimestamp()]){ error in + completition(error) + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/DisplayPictogramHolderView.swift b/nuevoamanecer/PictogramSection/Album/Views/DisplayPictogramHolderView.swift new file mode 100644 index 0000000..d2653b0 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/DisplayPictogramHolderView.swift @@ -0,0 +1,48 @@ +// +// DisplayPictogramHolderView.swift +// nuevoamanecer +// +// Created by emilio on 22/06/23. +// + +import SwiftUI +import AVFoundation + +struct DisplayPictogramHolderView: View { + var pictoModel: PictogramModel? + var catModel: CategoryModel? + @Binding var pictoInPage: PictogramInPage + + var pictoBaseWidth: CGFloat + var pictoBaseHeight: CGFloat + var spaceWidth: CGFloat + var spaceHeight: CGFloat + + var soundOn: Bool + var voiceGender: String + var talkingSpeed: String + let synthesizer: AVSpeechSynthesizer = AVSpeechSynthesizer() + + var body: some View { + VStack { + if pictoModel == nil || catModel == nil { + PictogramPlaceholderView() + } else { + Button { + let utterance = AVSpeechUtterance(string: pictoModel!.name) + + utterance.voice = voiceGender == "Masculina" ? AVSpeechSynthesisVoice(identifier: "com.apple.eloquence.es-MX.Reed") : AVSpeechSynthesisVoice(language: "es-MX") + + utterance.rate = talkingSpeed == "Normal" ? 0.5 : talkingSpeed == "Lenta" ? 0.3 : 0.7 + + synthesizer.speak(utterance) + } label: { + PictogramView(pictoModel: pictoModel!, catModel: catModel!, displayName: true, displayCatColor: true) + } + .allowsHitTesting(soundOn) + } + } + .frame(width: pictoBaseWidth * pictoInPage.scale, height: pictoBaseHeight * pictoInPage.scale) + .offset(x: (spaceWidth / 2) * pictoInPage.xOffset, y: (spaceHeight / 2) * pictoInPage.yOffset) + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/DoublePictogramPickerView.swift b/nuevoamanecer/PictogramSection/Album/Views/DoublePictogramPickerView.swift new file mode 100644 index 0000000..26dbea5 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/DoublePictogramPickerView.swift @@ -0,0 +1,85 @@ +// +// DoublePictogramPickerView.swift +// nuevoamanecer +// +// Created by emilio on 23/06/23. +// + +import SwiftUI +import AVFoundation +import Foundation + +struct BoardCoordinate { + var xPos: Double + var yPos :Double + var disToCenter: Double { + return sqrt(pow(xPos, 2) + pow(yPos, 2)) + } +} + +func boardCoordinates(disBetweenCoords: Double) -> [BoardCoordinate] { + var boardCoordinates: [BoardCoordinate] = [] + + for x in stride(from: -1, to: 1, by: disBetweenCoords){ + for y in stride(from: -1, to: 1, by: disBetweenCoords){ + boardCoordinates.append(BoardCoordinate(xPos: x, yPos: y)) + } + } + + return boardCoordinates +} + +func positionPictogramsInPage(pickedPictos: [String:PictogramInPage]) -> [PictogramInPage] { + var pictosInPage: [PictogramInPage] = [] + let boardCoordinates: [BoardCoordinate] = boardCoordinates(disBetweenCoords: 0.5).sorted{$0.disToCenter < $1.disToCenter} + var coordinateIndex: Int = 0 + + for var pictoInPage in pickedPictos.values { + pictoInPage.xOffset = boardCoordinates[coordinateIndex].xPos + pictoInPage.yOffset = boardCoordinates[coordinateIndex].yPos + coordinateIndex = (coordinateIndex + 1) % boardCoordinates.count + pictosInPage.append(pictoInPage) + } + + return pictosInPage +} + +struct DoublePictogramPickerView: View { + @Binding var pageModel: PageModel + @State var pickedPictos: [String:PictogramInPage] = [:] + + @Binding var isPresented: Bool + + var pictoCollectionPath1: String + var catCollectionPath1: String + var pictoCollectionPath2: String + var catCollectionPath2: String + + @State var showingPictogramPicker1: Bool = true + + var body: some View { + GeometryReader { geo in + ZStack { + // Pictogram Picker 1 + PictogramPickerView(pickedPictos: $pickedPictos, pictoCollectionPath: pictoCollectionPath1, catCollectionPath: catCollectionPath1, showSwitchView: true, onLeftOfSwitch: $showingPictogramPicker1) + .zIndex(showingPictogramPicker1 ? 1 : 0) + + // Pictogram Picker 2 + PictogramPickerView(pickedPictos: $pickedPictos, pictoCollectionPath: pictoCollectionPath2, catCollectionPath: catCollectionPath2, showSwitchView: true, onLeftOfSwitch: $showingPictogramPicker1) + .zIndex(showingPictogramPicker1 ? 0 : 1) + } + .overlay(alignment: .top) { + VStack { + let pictosChosen: Bool = pickedPictos.count > 0 + ButtonWithImageView(text: pictosChosen ? "Añadir Pictogramas" : "Cancelar", systemNameImage: pictosChosen ? "plus" : "xmark") { + if pictosChosen { + pageModel.pictogramsInPage += positionPictogramsInPage(pickedPictos: pickedPictos) + } + isPresented = false + } + } + .padding(.top, 10) + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/EditPictogramHolderView.swift b/nuevoamanecer/PictogramSection/Album/Views/EditPictogramHolderView.swift new file mode 100644 index 0000000..df7a33c --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/EditPictogramHolderView.swift @@ -0,0 +1,67 @@ +// +// PictogramHolderView.swift +// nuevoamanecer +// +// Created by emilio on 22/06/23. +// + +import SwiftUI + +struct EditPictogramHolderView: View { + var pictoModel: PictogramModel? + var catModel: CategoryModel? + @Binding var pictoInPage: PictogramInPage + var performWhenDeleted: (PictogramInPage)->Void + + var pictoBaseWidth: CGFloat + var pictoBaseHeight: CGFloat + var spaceWidth: CGFloat + var spaceHeight: CGFloat + + var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + let newXOffset: CGFloat = (value.translation.width / (spaceWidth / 2)) + pictoInPage.xOffset + let newYOffset: CGFloat = (value.translation.height / (spaceHeight / 2)) + pictoInPage.yOffset + + if newXOffset < 1 && newXOffset > -1 { + pictoInPage.xOffset = newXOffset + } + + if newYOffset < 1 && newYOffset > -1 { + pictoInPage.yOffset = newYOffset + } + } + } + + var body: some View { + VStack { + PictogramScaleModifierView(scale: $pictoInPage.scale, initialHeight: 50) + + VStack { + if pictoModel == nil || catModel == nil { + PictogramPlaceholderView() + .gesture(dragGesture) + } else { + PictogramView(pictoModel: pictoModel!, catModel: catModel!, displayName: true, displayCatColor: true) + .gesture(dragGesture) + } + } + .frame(width: pictoBaseWidth * pictoInPage.scale, height: pictoBaseHeight * pictoInPage.scale) + .overlay(alignment: .topTrailing){ + Image(systemName: "hand.point.up") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: (pictoBaseWidth * 0.15) * pictoInPage.scale) + .padding() + .foregroundColor(.gray) + .opacity(0.4) + } + + ButtonWithImageView(text: "Eliminar", systemNameImage: "trash", background: .red){ + performWhenDeleted(pictoInPage) + } + } + .offset(x: (spaceWidth / 2) * pictoInPage.xOffset, y: (spaceHeight / 2) * pictoInPage.yOffset) + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/PageBoardView.swift b/nuevoamanecer/PictogramSection/Album/Views/PageBoardView.swift new file mode 100644 index 0000000..ad86c92 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/PageBoardView.swift @@ -0,0 +1,66 @@ +// +// PageWhiteboardView.swift +// nuevoamanecer +// +// Created by emilio on 20/06/23. +// + +import SwiftUI + +struct PageBoardView: View { + @Binding var pageModel: PageModel + @ObservedObject var boardCache: BoardCache + + var pictoBaseWidth: CGFloat + var pictoBaseHeight: CGFloat + + var isEditing: Bool + + var soundOn: Bool = false + var voiceGender: String = "Femenina" + var talkingSpeed: String = "Normal" + + var body: some View { + GeometryReader { geo in + ZStack { + ForEach(0.. Void = { pictoInPage in + if let pictoIndex: Int = self.pageModel.pictogramsInPage.firstIndex(of: pictoInPage){ + self.pageModel.pictogramsInPage.remove(at: pictoIndex) + } + } + + if isEditing { + EditPictogramHolderView(pictoModel: pictoModel, + catModel: catModel, + pictoInPage: $pageModel.pictogramsInPage[i], + performWhenDeleted: performWhenDeleted, + pictoBaseWidth: pictoBaseWidth, + pictoBaseHeight: pictoBaseWidth, + spaceWidth: geo.size.width, + spaceHeight: geo.size.height) + } else { + DisplayPictogramHolderView(pictoModel: pictoModel, + catModel: catModel, + pictoInPage: $pageModel.pictogramsInPage[i], + pictoBaseWidth: pictoBaseWidth, + pictoBaseHeight: pictoBaseHeight, + spaceWidth: geo.size.width, + spaceHeight: geo.size.height, + soundOn: soundOn, + voiceGender: voiceGender, + talkingSpeed: talkingSpeed) + } + } + } + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + .task { + await boardCache.populateBoard(pictosInPage: pageModel.pictogramsInPage) + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/PageOptionsView.swift b/nuevoamanecer/PictogramSection/Album/Views/PageOptionsView.swift new file mode 100644 index 0000000..507163c --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/PageOptionsView.swift @@ -0,0 +1,86 @@ +// +// PageOptionsView.swift +// nuevoamanecer +// +// Created by emilio on 21/07/23. +// + +import SwiftUI + +struct VerticalEllipsisView: View { + var body: some View { + VStack(spacing: 3) { + Circle() + .frame(width: 6, height: 6) + Circle() + .frame(width: 6, height: 6) + Circle() + .frame(width: 6, height: 6) + } + } +} + +struct PageOptionsView: View { + var pageId: String + @Binding var showingOptionsOf: String? + + var patientId: String + @ObservedObject var pageVM: PageViewModel + var pageModel: PageModel + @ObservedObject var boardCache: BoardCache + + var performWhenDeleted: () -> Void + + var body: some View { + GeometryReader { geo in + VStack(spacing: 15) { + Button { + if showingOptionsOf != pageId { + showingOptionsOf = pageId + } else { + showingOptionsOf = nil + } + } label: { + VStack { + Image(systemName: "ellipsis") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray) + .opacity(0.6) + } + .frame(width: geo.size.width * 0.8, height: 10) + } + + if showingOptionsOf == pageId { + VStack(spacing: 10) { + NavigationLink { + PageEdit(patientId: patientId, pageVM: pageVM, pageModel: pageModel, boardCache: boardCache) + } label: { + HStack(spacing: 20) { + Text("Editar") + .foregroundColor(.white) + .bold() + + Image(systemName: "pencil") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 30) + } + .padding(10) + .frame(width: geo.size.width, height: 50) + .background(.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + + ButtonWithImageView(text: "Eliminar", systemNameImage: "trash", background: .red) { + performWhenDeleted() + } + } + } + } + .padding() + .frame(width: geo.size.width) + } + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/PictogramPickerView.swift b/nuevoamanecer/PictogramSection/Album/Views/PictogramPickerView.swift new file mode 100644 index 0000000..a862946 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/PictogramPickerView.swift @@ -0,0 +1,129 @@ +// +// PictogramPickerView.swift +// nuevoamanecer +// +// Created by emilio on 20/06/23. +// + +import SwiftUI +import AVFoundation + +struct PictogramPickerView: View { + @Binding var pickedPictos: [String:PictogramInPage] // [pictoId:PictogramModel] + + @StateObject var pictoVM: PictogramViewModel + @StateObject var catVM: CategoryViewModel + + @State var searchText: String = "" + @State var searchingPicto: Bool = true + @State var pickedCategoryId: String = "" + + @State var userHasChosenCat: Bool = false + + var showSwitchView: Bool + @Binding var onLeftOfSwitch: Bool + + init(pickedPictos: Binding<[String:PictogramInPage]>, pictoCollectionPath: String, catCollectionPath: String, showSwitchView: Bool = false, onLeftOfSwitch: Binding){ + _pickedPictos = pickedPictos + _pictoVM = StateObject(wrappedValue: PictogramViewModel(collectionPath: pictoCollectionPath)) + _catVM = StateObject(wrappedValue: CategoryViewModel(collectionPath: catCollectionPath)) + self.showSwitchView = showSwitchView + _onLeftOfSwitch = onLeftOfSwitch + } + + var body: some View { + let currCatColor: Color? = catVM.getCat(catId: pickedCategoryId)?.buildColor() + let pictosInScreen: [PictogramModel] = searchText.isEmpty ? pictoVM.getPictosFromCat(catId: pickedCategoryId) : + pictoVM.getPictosFromCat(catId: pickedCategoryId, nameFilter: searchText) + let catsInScreen: [CategoryModel] = searchText.isEmpty || searchingPicto ? catVM.getCats() : catVM.getCats(nameFilter: searchText) + + GeometryReader { geo in + VStack(spacing: 0) { + HStack { + PictogramSearchBarView(searchText: $searchText, searchBarWidth: geo.size.width * 0.25, searchingPicto: $searchingPicto) + .onChange(of: searchText) { _ in + if !searchingPicto { + // Se hace el filtrado por nombre dos veces. Esto quizás se podría evitar. + let catsInScreenIds: [String] = catVM.getCats(nameFilter: searchText).map {$0.id!} + if !catsInScreenIds.contains(pickedCategoryId) { + pickedCategoryId = catsInScreenIds.first ?? "" + } + } + } + + Spacer() + } + .frame(height: 40) + .background(Color.white) + .padding(.vertical) + .padding(.horizontal, 70) + + HStack(spacing: 15) { + Text("Categorias") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color.gray) + + if showSwitchView { + SwitchView(onLeft: $onLeftOfSwitch, leftText: "Base", rightText: "Personal", width: 200) + } + + Divider() + + HStack{ + // CategoryPickerView(categoryModels: catVM.getCats(), userPickedCategoryId: $pickedCategoryId, defaultPickedCategoryId: picked) + } + .background(Color.white) + .padding([.leading, .top, .bottom]) + Spacer() + } + .frame(height: 60) + .background(Color.white) + .padding(.vertical, 20) + .padding(.horizontal, 70) + + Rectangle() + .frame(height: 20.0, alignment: .bottom) + .foregroundColor(currCatColor ?? Color(red: 0.9, green: 0.9, blue: 0.9)) + + if pictosInScreen.count == 0 && (catsInScreen.count == 0 || !searchText.isEmpty) { + Text(!searchText.isEmpty && !searchingPicto ? "Sin resultados" : "No hay pictogramas") + .font(.system(size: 25, weight: .bold)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + } else { + PictogramGridView(pictograms: buildPictoViewButtons(pictosInScreen), pictoWidth: 165, pictoHeight: 165) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .onChange(of: catVM.categories) { _ in + if pickedCategoryId.isEmpty || !userHasChosenCat { + pickedCategoryId = catVM.getFirstCat()?.id! ?? "" + } + } + } + + private func buildPictoViewButtons(_ pictoModels: [PictogramModel]) -> [PictogramView] { + var pictoButtons: [PictogramView] = [] + + for pictoModel in pictoModels { + pictoButtons.append( + PictogramView(pictoModel: pictoModel, + catModel: catVM.getCat(catId: pictoModel.categoryId)!, + displayName: true, + displayCatColor: false, + overlayImage: pickedPictos[pictoModel.id!] != nil ? Image(systemName: "checkmark.circle") : nil, + overlayImageColor: .blue, + overlyImageOpacity: 0.8, + clickAction: { + if self.pickedPictos[pictoModel.id!] == nil { + self.pickedPictos[pictoModel.id!] = PictogramInPage(pictoId: pictoModel.id!, isBasePicto: self.onLeftOfSwitch) + } else { + self.pickedPictos.removeValue(forKey: pictoModel.id!) + } + }) + ) + } + return pictoButtons + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/PictogramPlaceholderView.swift b/nuevoamanecer/PictogramSection/Album/Views/PictogramPlaceholderView.swift new file mode 100644 index 0000000..30ea764 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/PictogramPlaceholderView.swift @@ -0,0 +1,16 @@ +// +// PictogramPlaceholderView.swift +// nuevoamanecer +// +// Created by emilio on 23/06/23. +// + +import SwiftUI + +struct PictogramPlaceholderView: View { + var body: some View { + Rectangle() + .foregroundColor(.gray) + .opacity(0.2) + } +} diff --git a/nuevoamanecer/PictogramSection/Album/Views/PictogramScaleModifierView.swift b/nuevoamanecer/PictogramSection/Album/Views/PictogramScaleModifierView.swift new file mode 100644 index 0000000..bfc867e --- /dev/null +++ b/nuevoamanecer/PictogramSection/Album/Views/PictogramScaleModifierView.swift @@ -0,0 +1,54 @@ +// +// PictogramScaleModifierView.swift +// nuevoamanecer +// +// Created by emilio on 25/06/23. +// + +import SwiftUI + +struct PictogramScaleModifierView: View { + @Binding var scale: Double + var initialHeight: CGFloat + + let scaleChangeValue: Double = 0.1 + var maxScale: Double = 3 + var minScale: Double = 0.5 + + var body: some View { + HStack(spacing: 20) { + Button { + if scale > minScale { + withAnimation(nil){ + scale -= scaleChangeValue + } + } + } label: { + Image(systemName: "minus.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.black) + } + + Text(String(format: "%.1f", scale)) + .font(.system(size: 20 * scale)) + + Button { + if scale < maxScale { + withAnimation(nil){ + scale += scaleChangeValue + } + } + } label: { + Image(systemName: "plus.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.black) + } + } + .padding() + .frame(height: initialHeight * scale) + .background(Color(red: 0.96, green: 0.96, blue: 0.96)) + .cornerRadius(20) + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/GenericViews/LockView.swift b/nuevoamanecer/PictogramSection/Communicator/GenericViews/LockView.swift new file mode 100644 index 0000000..d01729c --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/GenericViews/LockView.swift @@ -0,0 +1,91 @@ +// +// LockView.swift +// nuevoamanecer +// +// Created by emilio on 12/06/23. +// + +import SwiftUI + +struct LockView: View { + @EnvironmentObject var appLock: AppLock + var width: CGFloat = 150 + var height: CGFloat = 40 + + var longPressDuration: Double = 3 // Segundos + @State var longPressProgress: Double = 0 + @GestureState var longPressState: Bool = false + + private var longPressGesture: some Gesture { + LongPressGesture(minimumDuration: longPressDuration) + .updating($longPressState) { currState, prevState, _ in + if appLock.isLocked { + if prevState == false && currState == true { // Comienza a presionar + // Iniciar progreso + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + withAnimation{ + longPressProgress += 0.1 + } + + if longPressProgress >= longPressDuration { + longPressProgress = 0 + appLock.isLocked = false + timer.invalidate() + } else if longPressState == false { + longPressProgress = 0 + timer.invalidate() + } + } + } + + prevState = currState + } + } + } + + private var tapGesture: some Gesture { + TapGesture(count: 1) + .onEnded { + appLock.isLocked = true + } + } + + private var lockViewContent: some View { + ZStack { + Rectangle() + .frame(width: width, height: height) + .foregroundColor(appLock.isLocked ? .gray : .blue) + .cornerRadius(10) + .overlay(alignment: .leading){ + Rectangle() + .frame(width: width * (longPressProgress / longPressDuration), height: height) + .foregroundColor(.blue) + .cornerRadius(10) + } + + HStack(spacing: 5) { + Image(systemName: appLock.isLocked ? "lock.fill" : "lock.open.fill") + .resizable() + .aspectRatio(contentMode: .fit) + + Text(appLock.isLocked ? "Desbloquear" : "Bloquear") + } + .padding(10) + .frame(width: width, height: height) + .foregroundColor(Color.white) + .cornerRadius(10) + } + .frame(width: width, height: height) + } + + var body: some View { + if appLock.isLocked { + lockViewContent + .gesture(longPressGesture) + } else { + lockViewContent + .gesture(tapGesture) + } + + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/GenericViews/SwitchView.swift b/nuevoamanecer/PictogramSection/Communicator/GenericViews/SwitchView.swift new file mode 100644 index 0000000..6abe681 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/GenericViews/SwitchView.swift @@ -0,0 +1,66 @@ +// +// SwitchView.swift +// nuevoamanecer +// +// Created by emilio on 11/06/23. +// + +import SwiftUI + +struct SwitchView: View { + @Binding var onLeft: Bool + + var leftText: String + var rightText: String + var width: CGFloat = 300 + var height: CGFloat = 50 + var backgroundColor: Color = .white + var foregroundColor: Color = .blue + var textColor: Color = .black + + var isDisabled: Bool = false + + var body: some View { + ZStack { + Rectangle() + .frame(width: width, height: height) + .foregroundColor(backgroundColor) + .overlay(alignment: .center) { + Rectangle() + .frame(width: width/2, height: height) + .foregroundColor(isDisabled ? .gray : foregroundColor) + .cornerRadius(10) + .offset(x: onLeft ? ((width/2) * -1) + width/4: (width/2) - width/4) + } + + HStack(spacing: 0){ + Button { + withAnimation { + onLeft = true + } + } label: { + Text(leftText) + .frame(width: width/2, height: height) + .foregroundColor(textColor) + .bold() + } + .allowsHitTesting(!onLeft && !isDisabled) + + Button { + withAnimation { + onLeft = false + } + } label: { + Text(rightText) + .frame(width: width/2, height: height) + .foregroundColor(textColor) + .bold() + } + .allowsHitTesting(onLeft && !isDisabled) + } + .cornerRadius(10) + } + .background(backgroundColor) + .cornerRadius(10) + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/MainViews/Communicator.swift b/nuevoamanecer/PictogramSection/Communicator/MainViews/Communicator.swift new file mode 100644 index 0000000..c84752b --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/MainViews/Communicator.swift @@ -0,0 +1,167 @@ +// +// PictogramEditor.swift +// Comunicador +// +// Created by emilio on 27/05/23. +// + +import SwiftUI +import AVFoundation + +struct Communicator: View { + let patient: Patient? + let title: String? + + @StateObject var pictoVM: PictogramViewModel + @StateObject var catVM: CategoryViewModel + + @State var searchText: String = "" + @State var searchingPicto: Bool = true + @State var pickedCategoryId: String = "" + @State var userHasChosenCat: Bool = false + + @State var isConfiguring = false + let synthesizer: AVSpeechSynthesizer = AVSpeechSynthesizer() + + var showSwitchView: Bool + @Binding var onLeftOfSwitch: Bool + + @EnvironmentObject var appLock: AppLock + + @Binding var voiceSetting: VoiceSetting + + init(patient: Patient?, title: String?, showSwitchView: Bool = false, onLeftOfSwitch: Binding, voiceSetting: Binding){ + self.patient = patient + self.title = title + let pictoCollectionPath: String = patient != nil ? "User/\(patient!.id)/pictograms" : "basePictograms" + let catCollectionPath: String = patient != nil ? "User/\(patient!.id)/categories" : "baseCategories" + + self._pictoVM = StateObject(wrappedValue: PictogramViewModel(collectionPath: pictoCollectionPath)) + self._catVM = StateObject(wrappedValue: CategoryViewModel(collectionPath: catCollectionPath)) + self.showSwitchView = showSwitchView + self._onLeftOfSwitch = onLeftOfSwitch + + self._voiceSetting = voiceSetting + } + + var body: some View { + let currCatColor: Color? = catVM.getCat(catId: pickedCategoryId)?.buildColor() + let pictosInScreen: [PictogramModel] = searchText.isEmpty || !searchingPicto ? pictoVM.getPictosFromCat(catId: pickedCategoryId) : + pictoVM.getPictosFromCat(catId: pickedCategoryId, nameFilter: searchText) + let catsInScreen: [CategoryModel] = searchText.isEmpty || searchingPicto ? catVM.getCats() : catVM.getCats(nameFilter: searchText) + + GeometryReader { geo in + VStack(spacing: 0) { + HStack { + PictogramSearchBarView(searchText: $searchText, searchBarWidth: geo.size.width * 0.25, searchingPicto: $searchingPicto) + .onChange(of: searchText) { _ in + if !searchingPicto { + // Se hace el filtrado por nombre dos veces. Esto quizás se podría evitar. + let catsInScreenIds: [String] = catVM.getCats(nameFilter: searchText).map {$0.id!} + if !catsInScreenIds.contains(pickedCategoryId) { + pickedCategoryId = catsInScreenIds.first ?? "" + } + } + } + + if title != nil { + Text(title!) + .font(.system(size: 30)) + .padding(.horizontal, 25) + } + + Spacer() + + ButtonView(text: "Configuración Voz", color: .blue, isDisabled: appLock.isLocked) { + //modal con opciones de velocidad de pronunciacion y genero de voz + isConfiguring = true + } + .opacity(appLock.isLocked ? 0 : 1) + .font(.headline) + .sheet(isPresented: $isConfiguring) { + VoiceSettingView(voiceSetting: $voiceSetting) + } + + LockView() + } + .frame(height: 40) + .background(Color.white) + .padding(.vertical) + .padding(.horizontal, 70) + + HStack(spacing: 20) { + Text("Categorías") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color.gray) + + if showSwitchView { + SwitchView(onLeft: $onLeftOfSwitch, leftText: "Base", rightText: "Personal", width: 200) + } + + Divider() + + CategoryPickerView(categoryModels: catsInScreen, pickedCategoryId: $pickedCategoryId, userHasChosenCat: $userHasChosenCat) + } + .frame(height: 80) + .background(Color.white) + .padding(.horizontal, 70) + + Rectangle() + .frame(height: 20.0, alignment: .bottom) + .foregroundColor(currCatColor ?? Color(red: 0.9, green: 0.9, blue: 0.9)) + + if catsInScreen.count == 0 { + Color.white + } else if pictosInScreen.count == 0 && !searchText.isEmpty && searchingPicto { + Text("Sin resultados") + .font(.system(size: 25, weight: .bold)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + } else { + PictogramGridView(pictograms: buildPictoViewButtons(pictosInScreen), pictoWidth: 165, pictoHeight: 165) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .onChange(of: catVM.categories) { _ in + if !userHasChosenCat { + pickedCategoryId = catVM.getFirstCat()?.id! ?? "" + } + } + .navigationBarBackButtonHidden(appLock.isLocked) + } + + private func buildPictoViewButtons(_ pictoModels: [PictogramModel]) -> [PictogramView] { + var pictoButtons: [PictogramView] = [] + + for pictoModel in pictoModels { + pictoButtons.append( + PictogramView(pictoModel: pictoModel, + catModel: catVM.getCat(catId: pictoModel.categoryId)!, + displayName: true, + displayCatColor: false, + overlayImage: Image(systemName: "speaker.wave.3.fill"), + overlayImageColor: .gray, + overlyImageOpacity: 0.2, + clickAction: { + //text to speech + let utterance = AVSpeechUtterance(string: pictoModel.name) + + if (self.voiceSetting.voiceAge == "Infantil") { + utterance.voice = AVSpeechSynthesisVoice(language: "es-MX") + utterance.rate = 0.5 + utterance.pitchMultiplier = 1.5 + } else { + utterance.voice = self.voiceSetting.voiceGender == "Masculina" ? AVSpeechSynthesisVoice(identifier: "com.apple.eloquence.es-MX.Reed") : AVSpeechSynthesisVoice(language: "es-MX") + + utterance.rate = self.voiceSetting.talkingSpeed == "Normal" ? 0.5 : self.voiceSetting.talkingSpeed == "Lenta" ? 0.3 : 0.7 + } + + self.synthesizer.speak(utterance) + }) + ) + } + return pictoButtons + + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/MainViews/DoubleCommunicator.swift b/nuevoamanecer/PictogramSection/Communicator/MainViews/DoubleCommunicator.swift new file mode 100644 index 0000000..81de361 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/MainViews/DoubleCommunicator.swift @@ -0,0 +1,44 @@ +// +// PictogramEditor.swift +// Comunicador +// +// Created by emilio on 27/05/23. +// + +import SwiftUI +import AVFoundation + +struct DoubleCommunicator: View { + var patient: Patient + + @State var showingCommunicator1: Bool = true + + @State var appLock: AppLock = AppLock() + + @State var voiceSetting: VoiceSetting = VoiceSetting.defaultVoiceSetting() + var voiceSettingVM: VoiceSettingViewModel = VoiceSettingViewModel() + + var body: some View { + GeometryReader { geo in + ZStack { + // Communicator 1 (base) + Communicator(patient: nil, title: patient.buildPatientTitle(), showSwitchView: true, onLeftOfSwitch: $showingCommunicator1, voiceSetting: $voiceSetting) + .zIndex(showingCommunicator1 ? 1 : 0) + + // Communicator 2 (del usuario) + Communicator(patient: patient, title: patient.buildPatientTitle(), showSwitchView: true, onLeftOfSwitch: $showingCommunicator1, voiceSetting: $voiceSetting) + .zIndex(showingCommunicator1 ? 0 : 1) + } + } + .environmentObject(self.appLock) + .onAppear { + voiceSettingVM.getVoiceSetting(patientId: patient.id) { error, voiceSetting in + if error != nil || voiceSetting == nil { + self.voiceSetting = VoiceSetting.defaultVoiceSetting(patientId: patient.id) + } else { + self.voiceSetting = voiceSetting! + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/MainViews/SingleCommunicator.swift b/nuevoamanecer/PictogramSection/Communicator/MainViews/SingleCommunicator.swift new file mode 100644 index 0000000..9e250b9 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/MainViews/SingleCommunicator.swift @@ -0,0 +1,37 @@ +// +// SingleCommunicator.swift +// nuevoamanecer +// +// Created by emilio on 12/06/23. +// + +import SwiftUI + +struct SingleCommunicator: View { + var patient: Patient? + + @State var onLeftOfSwitch: Bool = true + + @State var appLock: AppLock = AppLock() + + @State var voiceSetting: VoiceSetting = VoiceSetting.defaultVoiceSetting() + var voiceSettingVM: VoiceSettingViewModel = VoiceSettingViewModel() + + var body: some View { + Communicator(patient: patient, title: patient?.buildPatientTitle(), showSwitchView: false, onLeftOfSwitch: $onLeftOfSwitch, voiceSetting: $voiceSetting) + .environmentObject(self.appLock) + .onAppear { + if patient == nil { + voiceSetting = VoiceSetting.defaultVoiceSetting() + } else { + voiceSettingVM.getVoiceSetting(patientId: patient!.id) { error, voiceSetting in + if error != nil || voiceSetting == nil { + self.voiceSetting = VoiceSetting.defaultVoiceSetting(patientId: patient!.id) + } else { + self.voiceSetting = voiceSetting! + } + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/Communicator/MiscStructures/AppLock.swift b/nuevoamanecer/PictogramSection/Communicator/MiscStructures/AppLock.swift new file mode 100644 index 0000000..8cf402a --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/MiscStructures/AppLock.swift @@ -0,0 +1,12 @@ +// +// AppLock.swift +// nuevoamanecer +// +// Created by emilio on 19/08/23. +// + +import Foundation + +class AppLock: ObservableObject { + @Published var isLocked: Bool = false +} diff --git a/nuevoamanecer/PictogramSection/Communicator/Views/VoiceSettingView.swift b/nuevoamanecer/PictogramSection/Communicator/Views/VoiceSettingView.swift new file mode 100644 index 0000000..3d5ee73 --- /dev/null +++ b/nuevoamanecer/PictogramSection/Communicator/Views/VoiceSettingView.swift @@ -0,0 +1,268 @@ +// +// VoiceConfigurationView.swift +// Comunicador +// +// Created by Alumno on 05/06/23. +// + +import SwiftUI +import AVFoundation + +struct VoiceSettingView: View { + @Environment(\.dismiss) var dismiss + + var voiceAgeList = ["Adulta", "Infantil"] + var speedList = ["Lenta", "Normal", "Rápida"] + var voiceList = ["Masculina", "Femenina"] + + let synthesizer: AVSpeechSynthesizer = AVSpeechSynthesizer() + + @Binding var voiceSetting: VoiceSetting + @State var voiceSettingCapture: VoiceSetting + + var voiceSettingVM: VoiceSettingViewModel = VoiceSettingViewModel() + + init(voiceSetting: Binding){ + self._voiceSetting = voiceSetting + self._voiceSettingCapture = State(initialValue: voiceSetting.wrappedValue) + } + + var body: some View { + + VStack{ + VStack{ + Text("Configuración de voz") + .font(.largeTitle.bold()) + .padding(.bottom, 10) + Text("La configuración que elijas se quedará guardada y aplicada.") + .font(.subheadline) + .foregroundColor(.gray) + .padding(.bottom, 10) + } + .padding(20) + .padding(.top, 30) + + Form { + Section(header: Text("Configuración")) { + + Picker("Tipo de voz", selection: $voiceSetting.voiceAge) { + ForEach(voiceAgeList, id: \.self) { age in + Text(age) + } + } + .pickerStyle(MenuPickerStyle()) + + if(voiceSetting.voiceAge == "Adulta"){ + Picker("Género", selection: $voiceSetting.voiceGender) { + ForEach(voiceList, id: \.self) { voice in + Text(voice) + } + } + .pickerStyle(MenuPickerStyle()) + + + Picker("Velocidad de pronunciación", selection: $voiceSetting.talkingSpeed) { + ForEach(speedList, id: \.self) { speed in + Text(speed) + } + } + .pickerStyle(MenuPickerStyle()) + } + } + + HStack{ + /* + //Cancel + Button(action: { + dismiss() + }){ + HStack { + Text("Cerrar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + */ + + + //Probar + Button(action: { + //text to speech + let utterance = AVSpeechUtterance(string: "Nuevo Amanecer") + + if (voiceSetting.voiceAge == "Infantil") { + utterance.voice = AVSpeechSynthesisVoice(language: "es-MX") + utterance.rate = 0.5 + utterance.pitchMultiplier = 1.5 + } else { + utterance.voice = voiceSetting.voiceGender == "Masculina" ? AVSpeechSynthesisVoice(identifier: "com.apple.eloquence.es-MX.Reed") : AVSpeechSynthesisVoice(language: "es-MX") + + utterance.rate = voiceSetting.talkingSpeed == "Normal" ? 0.5 : voiceSetting.talkingSpeed == "Lenta" ? 0.3 : 0.7 + } + + synthesizer.speak(utterance) + }){ + HStack { + Text("Probar") + .font(.headline) + + Spacer() + Image(systemName: "speaker.wave.2") + } + } + .padding() + .background(Color.green) + .cornerRadius(10) + .foregroundColor(.white) + /* + ButtonWithImageView(text: "Probar", width: 157, systemNameImage: "speaker.wave.2", background: .green) { + //text to speech + let utterance = AVSpeechUtterance(string: "Nuevo Amanecer") + + if (voiceSetting.voiceAge == "Infantil") { + utterance.voice = AVSpeechSynthesisVoice(language: "es-MX") + utterance.rate = 0.5 + utterance.pitchMultiplier = 1.5 + } else { + utterance.voice = voiceSetting.voiceGender == "Masculina" ? AVSpeechSynthesisVoice(identifier: "com.apple.eloquence.es-MX.Reed") : AVSpeechSynthesisVoice(language: "es-MX") + + utterance.rate = voiceSetting.talkingSpeed == "Normal" ? 0.5 : voiceSetting.talkingSpeed == "Lenta" ? 0.3 : 0.7 + } + + synthesizer.speak(utterance) + } + */ + } + } + } + /* + VStack { + + + Spacer() + .frame(height: 40) + + HStack { + Text("Género de voz") + .font(.title2) + + Spacer() + Menu { + Picker("Género", selection: $voiceSetting.voiceGender) { + ForEach(voiceList, id: \.self) { voice in + Text(voice) + } + } + } label: { + Text(voiceSetting.voiceGender) + .font(.title2) + Image(systemName: "chevron.down") + } + .padding() + .background(Color.secondary.opacity(0.2)) + .cornerRadius(8) + .foregroundColor(.primary) + } + .padding(20) + + Spacer() + .frame(height: 20) + + HStack { + Text("Velocidad de pronunciación") + .font(.title2) + Spacer() + Menu { + Picker("Velocidad", selection: $voiceSetting.talkingSpeed) { + ForEach(speedList, id: \.self) { speed in + Text(speed) + } + } + } label: { + Text(voiceSetting.talkingSpeed) + .font(.title2) + Image(systemName: "chevron.down") + } + .padding() + .background(Color.secondary.opacity(0.2)) + .cornerRadius(8) + .foregroundColor(.primary) + } + .padding(20) + + Spacer() + .frame(height: 20) + + HStack { + Text("Tipo de voz") + .font(.title2) + Spacer() + Menu { + Picker("Tipo", selection: $voiceSetting.voiceAge) { + ForEach(voiceAgeList, id: \.self) { age in + Text(age) + } + } + } label: { + Text(voiceSetting.voiceAge) + .font(.title2) + Image(systemName: "chevron.down") + } + .padding() + .background(Color.secondary.opacity(0.2)) + .cornerRadius(8) + .foregroundColor(.primary) + } + .padding(20) + + HStack { + ButtonWithImageView(text: "Probar", systemNameImage: "speaker.wave.2", background: .gray) { + //text to speech + let utterance = AVSpeechUtterance(string: "Nuevo Amanecer") + + if (voiceSetting.voiceAge == "Infantil") { + utterance.voice = AVSpeechSynthesisVoice(language: "es-MX") + utterance.rate = 0.5 + utterance.pitchMultiplier = 1.5 + } else { + utterance.voice = voiceSetting.voiceGender == "Masculina" ? AVSpeechSynthesisVoice(identifier: "com.apple.eloquence.es-MX.Reed") : AVSpeechSynthesisVoice(language: "es-MX") + + utterance.rate = voiceSetting.talkingSpeed == "Normal" ? 0.5 : voiceSetting.talkingSpeed == "Lenta" ? 0.3 : 0.7 + } + + synthesizer.speak(utterance) + } + + ButtonView(text: "Regresar", color: .blue) { + dismiss() + } + } + } + */ + .onDisappear { + if voiceSetting.patientId != nil && voiceSetting != voiceSettingCapture { + if voiceSetting.id != nil { + voiceSettingVM.editVoiceSetting(voiceSettingId: voiceSetting.id!, voiceSetting: voiceSetting) { error in + if error != nil { + // Error + } + } + } else { + voiceSettingVM.createVoiceSetting(voiceSetting: voiceSetting) { error, id in + if error != nil || id == nil { + // Error + } else { + voiceSetting.id = id! + } + } + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Extensions/PictogramStringExtensions.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Extensions/PictogramStringExtensions.swift new file mode 100644 index 0000000..21f4917 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Extensions/PictogramStringExtensions.swift @@ -0,0 +1,14 @@ +// +// StringExtensions.swift +// nuevoamanecer +// +// Created by emilio on 26/08/23. +// + +import Foundation + +extension String { + func cleanForSearch() -> String { + return self.lowercased().trimmingCharacters(in: .whitespaces) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonView.swift new file mode 100644 index 0000000..2b719cd --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonView.swift @@ -0,0 +1,28 @@ +// +// MiscButton.swift +// Comunicador +// +// Created by emilio on 28/05/23. +// + +import SwiftUI + +struct ButtonView: View { + var text: String + var textSize: CGFloat = 15 + var color: Color + var isDisabled: Bool = false + var buttonAction: () -> Void + + var body: some View { + Button(text) { + buttonAction() + } + .padding(10) + .background(!isDisabled ? color : .gray) + .foregroundColor(.white) + .cornerRadius(10) + .font(.system(size: textSize, weight: .regular, design: .default)) + .allowsHitTesting(!isDisabled) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonWithImageView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonWithImageView.swift new file mode 100644 index 0000000..926bb94 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ButtonWithImageView.swift @@ -0,0 +1,52 @@ +// +// ButtonViewImage.swift +// nuevoamanecer +// +// Created by emilio on 14/06/23. +// + +import SwiftUI + +enum ImagePosition { + case left + case right +} + +struct ButtonWithImageView: View { + var text: String + var textSize: CGFloat = 15 + var width: CGFloat? = nil + + var systemNameImage: String + var imagePosition: ImagePosition = .right + var imagePadding: CGFloat = 10 + + var background: Color = Color.blue + var isDisabled: Bool = false + var action: () -> Void + + var body: some View { + Button(action: self.action){ + HStack { + if imagePosition == .left { + Image(systemName: systemNameImage) + .padding(.trailing, imagePadding) + } + + Text(text) + .font(.system(size: textSize)) + + if imagePosition == .right { + Image(systemName: systemNameImage) + .padding(.leading, imagePadding) + } + } + .padding(10) + .frame(width: width) + .background(isDisabled ? Color.gray : self.background) + .cornerRadius(10) + .foregroundColor(.white) + } + .allowsHitTesting(!isDisabled) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ColorPickerView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ColorPickerView.swift new file mode 100644 index 0000000..df279c4 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/ColorPickerView.swift @@ -0,0 +1,75 @@ +// +// MiscColorPickerView.swift +// Comunicador +// +// Created by emilio on 04/06/23. +// + +import SwiftUI + +struct ColorInputSlider: View { + var trackHeight: CGFloat + var trackForeground: Color + @Binding var inputNumber: Double // 0-1 + + var body: some View { + Slider(value: $inputNumber, in: 0...1) + .frame(height: trackHeight) + .tint(trackForeground) + } +} + +struct ColorPickerView: View { + @Binding var red: Double // 0-1 + @Binding var green: Double // 0-1 + @Binding var blue: Double // 0-1 + + var body: some View { + GeometryReader { geo in + VStack(spacing: 20){ + ScrollView(.horizontal){ + let basicColors: [(r: Double, g: Double, b: Double)] = ColorMaker.getBasicColors() + HStack(spacing: 0) { + ForEach(0.. Void + + var longPressDuration: Double = 3 // Segundos + @State var longPressProgress: Double = 0 + @GestureState var longPressState: Bool = false + + private var longPressGesture: some Gesture { + LongPressGesture(minimumDuration: longPressDuration) + .updating($longPressState) { currState, prevState, _ in + if prevState == false && currState == true { // Comienza a presionar + // Iniciar progreso + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + withAnimation{ + longPressProgress += 0.1 + } + + if longPressProgress >= longPressDuration { + longPressProgress = 0 + timer.invalidate() + action() + } else if longPressState == false { + longPressProgress = 0 + timer.invalidate() + } + } + } + + prevState = currState + } + } + + private var viewContent: some View { + ZStack { + Rectangle() + .frame(width: width, height: height) + .foregroundColor(background) + .cornerRadius(10) + .overlay(alignment: .leading){ + Rectangle() + .frame(width: width * (longPressProgress / longPressDuration), height: height) + .foregroundColor(overlayedBackground) + .cornerRadius(10) + } + + HStack(spacing: 10) { + if imagePosition == .left { + Image(systemName: systemNameImage) + } + + Text(text) + .font(.system(size: textSize)) + + if imagePosition == .right { + Image(systemName: systemNameImage) + } + } + .padding(10) + .frame(width: width) + .foregroundColor(Color.white) + .cornerRadius(10) + } + .frame(width: width) + } + + var body: some View { + if isDisabled { + viewContent + } else { + viewContent + .gesture(longPressGesture) + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/MarkedScrollView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/MarkedScrollView.swift new file mode 100644 index 0000000..c854834 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/MarkedScrollView.swift @@ -0,0 +1,83 @@ +// +// MarkedScrollView.swift +// nuevoamanecer +// +// Created by emilio on 22/08/23. +// + +import SwiftUI + +enum MarkedScrollViewDirection { + case horizontal, vertical +} + +struct MarkedScrollView: View { + let scrollDirection: MarkedScrollViewDirection + let offset: CGFloat = 20 + @ViewBuilder let content: () -> Content + @State private var scrollViewFrame: CGRect = .zero + @State private var contentFrame: CGRect = .zero + + var body: some View { + ScrollView(scrollDirection == .horizontal ? .horizontal : .vertical){ + content() + .background { + GeometryReader { contentGeo in + Color.clear + .onAppear { + contentFrame = contentGeo.frame(in: .global) + } + .onChange(of: contentGeo.frame(in: .global)) { newContentFrame in + contentFrame = newContentFrame + } + } + } + } + .coordinateSpace(name: "markedScrollViewCoordSpace") + .background { + GeometryReader { scrollViewGeo in + Color.clear + .onAppear { + scrollViewFrame = scrollViewGeo.frame(in: .global) + } + .onChange(of: scrollViewGeo.frame(in: .global)) { newScrollViewFrame in + scrollViewFrame = newScrollViewFrame + } + } + } + .overlay(alignment: .center) { + if scrollDirection == .horizontal { + marksHorizontal + } else { + marksVertical + } + } + } + + var marksHorizontal: some View { + HStack { + Image(systemName: "arrowshape.backward.fill") + .opacity(contentFrame.minX < scrollViewFrame.minX - offset ? 0.9 : 0) + Spacer() + .frame(minWidth: scrollViewFrame.size.width + 5) + Image(systemName: "arrowshape.right.fill") + .opacity(contentFrame.maxX > scrollViewFrame.maxX + offset ? 0.9 : 0) + } + .allowsHitTesting(false) + .foregroundColor(.gray) + } + + var marksVertical: some View { + VStack { + Image(systemName: "arrowshape.backward.fill") + .rotationEffect(.degrees(90)) + .opacity(contentFrame.minY < scrollViewFrame.minY - offset ? 0.9 : 0) + Spacer() + Image(systemName: "arrowshape.right.fill") + .rotationEffect(.degrees(90)) + .opacity(contentFrame.maxY > scrollViewFrame.maxY + offset ? 0.9 : 0) + } + .allowsHitTesting(false) + .foregroundColor(.gray) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/SearchBarView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/SearchBarView.swift new file mode 100644 index 0000000..a5e3880 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/SearchBarView.swift @@ -0,0 +1,40 @@ +// +// MiscSearchBar.swift +// Comunicador +// +// Created by emilio on 28/05/23. +// + +import SwiftUI + +struct SearchBarView: View { + @Binding var searchText: String + let placeholder: String + var searchBarWidth: Double + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(Color.gray) + .padding([.bottom, .top, .leading]) + + TextField(placeholder, text: $searchText) + .padding([.bottom, .top, .trailing]) + + if !searchText.isEmpty { + Button(action: { + searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + .padding(.trailing, 20) + } + } + } + .frame(width: searchBarWidth) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/TextFieldView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/TextFieldView.swift new file mode 100644 index 0000000..9d2753c --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/TextFieldView.swift @@ -0,0 +1,51 @@ +// +// MiscTextFieldView.swift +// Comunicador +// +// Created by emilio on 31/05/23. +// + +import SwiftUI +import Combine + +struct TextFieldView: View { + var fieldWidth: CGFloat + var fieldHeight: CGFloat? = nil + var fontSize: CGFloat = 20 + var placeHolder: String + var background: Color = Color(red: 0.7, green: 0.7, blue: 0.7) + @Binding var inputText: String + var maxCharLength: Int? = nil + + var body: some View { + HStack(spacing: 0) { + let textField: some View = TextField(placeHolder, text: $inputText) + .font(.system(size: fontSize)) + .padding() + + if maxCharLength != nil { + textField + .onReceive(Just(inputText)) { _ in + if inputText.count > maxCharLength! { + inputText = String(inputText.prefix(maxCharLength!)) + } + } + } else { + textField + } + + if !inputText.isEmpty { + Button(action: { + inputText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + } + .padding() + } + } + .frame(width: fieldWidth, height: fieldHeight) + .background(background) + .cornerRadius(10) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/XOverCircleView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/XOverCircleView.swift new file mode 100644 index 0000000..c8c97af --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/GenericViews/XOverCircleView.swift @@ -0,0 +1,31 @@ +// +// xOverCircle.swift +// Comunicador +// +// Created by emilio on 06/06/23. +// + +import SwiftUI + +struct XOverCircleView: View { + var diameter: Double = 20 + + var body: some View { + Circle() + .frame(width: diameter, height: diameter) + .foregroundColor(.black) + .overlay(alignment: .center) { + Circle() + .frame(width: diameter * 0.9, height: diameter * 0.9) + .foregroundColor(.white) + .overlay(alignment: .center) { + Image(systemName: "xmark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: diameter * 0.5) + .foregroundColor(.red) + } + } + } +} + diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/MainViews/PictogramEditor.swift b/nuevoamanecer/PictogramSection/PictogramEditor/MainViews/PictogramEditor.swift new file mode 100644 index 0000000..2a2c5c8 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/MainViews/PictogramEditor.swift @@ -0,0 +1,170 @@ +// +// PictogramEditor.swift +// Comunicador +// +// Created by emilio on 27/05/23. +// + +// Importamos SwiftUI +import SwiftUI + +// Definimos el struct PictogramEditor que cumple con el protocolo View +struct PictogramEditor: View { + let patient: Patient? + + // Inicializamos los objetos que manejarán los pictogramas y las categorías + @StateObject var pictoVM: PictogramViewModel + @StateObject var catVM: CategoryViewModel + + // Estados de la interfaz gráfica + @State var searchText: String = "" // Texto de búsqueda + @State var searchingPicto: Bool = true + @State var pickedCategoryId: String = "" // ID de la categoría seleccionada + @State var userHasChosenCat: Bool = false // Si el usuario ha seleccionado una categoría + + @State var pictoBeingEdited: PictogramModel = PictogramModel.newEmptyPictogram() // Pictograma que se está editando + @State var catBeingEdited: CategoryModel = CategoryModel.newEmptyCategory() // Categoría que se está editando + @State var isEditingPicto: Bool = false // Si se está editando un pictograma + @State var isEditingCat: Bool = false // Si se está editando una categoría + @State var showErrorMessage: Bool = false // Si se muestra un mensaje de error + + // Handler para las imágenes en Firebase + var imageHandler: FirebaseAlmacenamiento = FirebaseAlmacenamiento() + + // Inicializador del PictogramEditor + init(patient: Patient?){ + self.patient = patient + let pictoCollectionPath: String = patient != nil ? "User/\(patient!.id)/pictograms" : "basePictograms" + let catCollectionPath: String = patient != nil ? "User/\(patient!.id)/categories" : "baseCategories" + + // Inicializamos los ViewModel con los paths correspondientes + self._pictoVM = StateObject(wrappedValue: PictogramViewModel(collectionPath: pictoCollectionPath)) + self._catVM = StateObject(wrappedValue: CategoryViewModel(collectionPath: catCollectionPath)) + } + + // Cuerpo de la vista + var body: some View { + // Obtenemos la categoría actual y los pictogramas correspondientes + let currCat: CategoryModel? = catVM.getCat(catId: pickedCategoryId) + let pictosInScreen: [PictogramModel] = searchText.isEmpty || !searchingPicto ? pictoVM.getPictosFromCat(catId: pickedCategoryId) : + pictoVM.getPictosFromCat(catId: pickedCategoryId, nameFilter: searchText) + let catsInScreen: [CategoryModel] = searchText.isEmpty || searchingPicto ? catVM.getCats() : catVM.getCats(nameFilter: searchText) + + GeometryReader { geo in + VStack(spacing: 0) { + // Barra superior con botones para eliminar y agregar pictogramas + HStack { + PictogramSearchBarView(searchText: $searchText, searchBarWidth: geo.size.width * 0.25, searchingPicto: $searchingPicto) + .onChange(of: searchText) { _ in + if !searchingPicto { + // Se hace el filtrado por nombre dos veces. Esto quizás se podría evitar. + let catsInScreenIds: [String] = catVM.getCats(nameFilter: searchText).map {$0.id!} + if !catsInScreenIds.contains(pickedCategoryId) { + pickedCategoryId = catsInScreenIds.first ?? "" + } + } + } + + if patient != nil { + Text(patient!.buildPatientTitle()) + .font(.system(size: 30)) + .padding(.horizontal, 25) + } + + Spacer() + } + .frame(height: 40) + .background(Color.white) + .padding(.vertical) + .padding(.horizontal, 70) + + // Barra de búsqueda, selector de categoría y botones para editar y agregar categorías + HStack(spacing: 15){ + + Text("Categorias") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color.gray) + + HStack { + let editCatButtonisDisabled: Bool = pickedCategoryId.isEmpty || catVM.getCat(catId: pickedCategoryId) == nil + //Editar categoria + ButtonWithImageView(text: "Editar", width: 120, systemNameImage: "pencil", imagePosition: .left, imagePadding: 2, isDisabled: editCatButtonisDisabled){ + catBeingEdited = catVM.getCat(catId: pickedCategoryId) ?? CategoryModel.newEmptyCategory() + isEditingCat = true + } + + //Agregar categoria + ButtonWithImageView(text: "Agregar", width: 120, systemNameImage: "plus", imagePosition: .left, imagePadding: 2){ + catBeingEdited = CategoryModel.newEmptyCategory() + isEditingCat = true + } + } + .padding([.leading, .top, .bottom]) + .padding(.trailing, 20) + + Divider() + + CategoryPickerView(categoryModels: catsInScreen, pickedCategoryId: $pickedCategoryId, userHasChosenCat: $userHasChosenCat) + } + .frame(height: 80) + .background(Color.white) + .padding(.horizontal, 70) + + Rectangle() + .frame(height: 20.0, alignment: .bottom) + .foregroundColor(currCat?.buildColor() ?? Color(red: 0.9, green: 0.9, blue: 0.9)) + + // Cuadrícula de pictogramas + if catsInScreen.count == 0 { + Color.white + } else if pictosInScreen.count == 0 && !searchText.isEmpty && searchingPicto { + Text("Sin resultados") + .font(.system(size: 25, weight: .bold)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white) + } else { + PictogramGridView(pictograms: buildPictoViewButtons(pictosInScreen), pictoWidth: 165, pictoHeight: 165) { + pictoBeingEdited = PictogramModel.newEmptyPictogram(catId: pickedCategoryId) + isEditingPicto = true + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .sheet(isPresented: $isEditingPicto) { [pictoBeingEdited] in + PictogramEditorWindowView(pictoModel: pictoBeingEdited, pictoVM: pictoVM, catVM: catVM) + } + .sheet(isPresented: $isEditingCat) { [catBeingEdited] in + CategoryEditorWindowView(catModel: catBeingEdited, pictoVM: pictoVM, catVM: catVM, pickedCategoryId: $pickedCategoryId, searchText: $searchText) + } + .customAlert(title: "Error", message: "La operación no pudo ser realizada", isPresented: $showErrorMessage) // Alerta de error + } + .onChange(of: catVM.categories) { _ in + if !userHasChosenCat { + pickedCategoryId = catVM.getFirstCat()?.id! ?? "" + } + } + } + + // Función que construye los botones de los pictogramas + private func buildPictoViewButtons(_ pictoModels: [PictogramModel]) -> [PictogramView] { + var pictoButtons: [PictogramView] = [] + + for pictoModel in pictoModels { + pictoButtons.append( + PictogramView(pictoModel: pictoModel, + catModel: catVM.getCat(catId: pictoModel.categoryId)!, + displayName: true, + displayCatColor: false, + overlayImage: Image(systemName: "pencil"), + overlayImageWidth: 0.2, + overlayImageColor: .gray, + overlyImageOpacity: 0.2, + clickAction: { + self.pictoBeingEdited = pictoModel + self.isEditingPicto = true + }) + ) + } + return pictoButtons + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomAlert.swift b/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomAlert.swift new file mode 100644 index 0000000..db1e7e1 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomAlert.swift @@ -0,0 +1,32 @@ +// +// customAlert.swift +// Comunicador +// +// Created by emilio on 03/06/23. +// + +import Foundation +import SwiftUI + +struct CustomAlert: ViewModifier { + var title: String + var message: String + @Binding var isPresented: Bool + + func body(content: Content) -> some View { + content + .alert(title, isPresented: $isPresented) { + Button("Cerrar") { + isPresented = false + } + } message: { + Text(message) + } + } +} + +extension View { + func customAlert(title: String, message: String, isPresented: Binding) -> some View { + self.modifier(CustomAlert(title: title, message: message, isPresented: isPresented)) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomConfirmAlert.swift b/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomConfirmAlert.swift new file mode 100644 index 0000000..e0a94fd --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/MiscModifiers/CustomConfirmAlert.swift @@ -0,0 +1,39 @@ +// +// CustomConfirmAlert.swift +// nuevoamanecer +// +// Created by emilio on 14/06/23. +// + +import Foundation +import SwiftUI + +struct CustomConfirmAlert: ViewModifier { + var title: String + var message: String + @Binding var isPresented: Bool + var action: () -> Void + + func body(content: Content) -> some View { + content + .alert(title, isPresented: $isPresented) { + HStack { + Button("Cancelar", role: .cancel) { + isPresented = false + } + + Button("Eliminar", role: .destructive) { + action() + } + } + } message: { + Text(message) + } + } +} + +extension View { + func customConfirmAlert(title: String, message: String, isPresented: Binding, action: @escaping ()->Void) -> some View { + self.modifier(CustomConfirmAlert(title: title, message: message, isPresented: isPresented, action: action)) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/ColorMaker.swift b/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/ColorMaker.swift new file mode 100644 index 0000000..1e79777 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/ColorMaker.swift @@ -0,0 +1,83 @@ +// +// ColorMaker.swift +// Comunicador +// +// Created by emilio on 05/06/23. +// + +import Foundation +import SwiftUI + +struct ColorMaker { + static func buildforegroundTextColor(r: Double, g: Double, b: Double) -> Color { + return ColorMaker.colorLuminance(r: r, g: g, b: b) < 0.6 ? .white : .black + } + + static func buildforegroundTextColor(catColor: CategoryColor) -> Color { + return ColorMaker.colorLuminance(catColor: catColor) < 0.6 ? .white : .black + } + + static func colorLuminance(r: Double, g: Double, b: Double) -> Double { + return (r * 0.2126) + (g * 0.7152) + (b * 0.0722) + } + + static func colorLuminance(catColor: CategoryColor) -> Double { + return (catColor.r * 0.2126) + (catColor.g * 0.7152) + (catColor.b * 0.0722) + } + + static func getBasicColors() -> [(r: Double, g: Double, b: Double)] { + let colors: [(r: Double, g: Double, b: Double)] = [ + // Red shades + (1, 0, 0), // Red + (0.75, 0, 0), // Maroon + (0.5, 0, 0), // Dark Red + + // Orange shades + (1, 0.5, 0), // Orange + (0.75, 0.25, 0), // Burnt Orange + (0.5, 0.25, 0), // Dark Orange + + // Yellow shades + (1, 1, 0), // Yellow + (0.75, 0.75, 0), // Gold + (0.5, 0.5, 0), // Dark Yellow + + // Green shades + (0, 1, 0), // Green + (0, 0.75, 0), // Lime + (0, 0.5, 0), // Dark Green + + // Blue shades + (0, 0, 1), // Blue + (0, 0, 0.75), // Navy + (0, 0, 0.5), // Dark Blue + + // Purple shades + (0.5, 0, 0.5), // Purple + (0.25, 0, 0.5), // Indigo + (0.25, 0, 0.25), // Dark Purple + + // Pink shades + (1, 0, 1), // Pink + (0.75, 0, 0.75), // Magenta + (0.5, 0, 0.5), // Dark Pink + + // Brown shades + (0.6, 0.4, 0.2), // Brown + (0.5, 0.25, 0), // Dark Brown + (0.4, 0.2, 0), // Chocolate + + // Gray shades + (0.75, 0.75, 0.75), // Silver + (0.5, 0.5, 0.5), // Gray + (0.25, 0.25, 0.25), // Dark Gray + + // Other colors + (1, 1, 1), // White + (0, 0, 0), // Black + ] + + return colors + } + +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/SortedArray.swift b/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/SortedArray.swift new file mode 100644 index 0000000..0c7e3ea --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/MiscStructures/SortedArray.swift @@ -0,0 +1,92 @@ +// +// SortedArray.swift +// Comunicador +// +// Created by emilio on 24/05/23. +// + +import Foundation + +class SortedArray { + private var items: [T] = [] + var count: Int { + return items.count + } + + func add(item: T) -> Void { + if items.isEmpty || items.last! <= item { + items.append(item) + return + } + + let indexOfPrevElem: Int = findItemPos(item: item) + items.insert(item, at: indexOfPrevElem + 1) + } + + func remove(item: T) -> Void { + if items.isEmpty { + return + } + + let itemIndex: Int? = findItem(item: item) + + if let unwrappedIndex: Int = itemIndex { + items.remove(at: unwrappedIndex) + } + } + + func updateWith(item: T, with newItem: T) -> Void { + self.remove(item: item) + self.add(item: newItem) + } + + func getItems(descending: Bool = false) -> [T] { + if descending { + var itemsDescending: [T] = [] + for i in (self.count-1)...0 { + itemsDescending.append(self.items[i]) + } + return itemsDescending + } else { + return self.items + } + } + + private func findItem(item: T) -> Int? { + var l: Int = 0 + var r: Int = items.count - 1 + + while l <= r { + let mid: Int = (l + r) / 2 + + if item == items[mid] { + return mid + } else if items[mid] < item { + l = mid + 1 + } else { + r = mid - 1 + } + } + + return nil + } + + private func findItemPos(item: T) -> Int { + var l: Int = 0 + var r: Int = items.count - 1 + + while l <= r { + let mid: Int = (l + r) / 2 + + if items[mid] <= item && (mid+1 < items.count ? item <= items[mid+1] : true) { + return mid + } else if items[mid] < item { + l = mid + 1 + } else { + r = mid - 1 + } + } + + return r + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Models/CategoryModel.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Models/CategoryModel.swift new file mode 100644 index 0000000..2d82bb3 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Models/CategoryModel.swift @@ -0,0 +1,136 @@ +// +// CategoryModel.swift +// Comunicador +// +// Created by emilio on 25/05/23. +// + +import SwiftUI +import Foundation +import FirebaseFirestoreSwift + +struct CategoryModel: Identifiable, Codable, Comparable { + @DocumentID var id: String? + var name: String + var color: CategoryColor + + func isValidCateogry() -> Bool { + if name.isEmpty || name.count > 20 { // Definir número máximo de caracteres. + return false + } else if !color.isValidCategoryColor() { + return false + } + return true + } + + func buildColor() -> Color { + return Color(red: color.r, green: color.g, blue: color.b) + } + + func buildColor(colorShift: Double) -> Color { + var rShifted: Double = color.r + colorShift + var gShifted: Double = color.g + colorShift + var bShifted: Double = color.b + colorShift + rShifted = rShifted > 1 ? 1 : (rShifted < 0 ? 0 : rShifted) + gShifted = gShifted > 1 ? 1 : (gShifted < 0 ? 0 : gShifted) + bShifted = bShifted > 1 ? 1 : (bShifted < 0 ? 0 : bShifted) + + return Color(red: rShifted, green: gShifted, blue: bShifted) + } + + func buildColorCatColor(colorShift: Double) -> CategoryColor { + var rShifted: Double = color.r + colorShift + var gShifted: Double = color.g + colorShift + var bShifted: Double = color.b + colorShift + rShifted = rShifted > 1 ? 1 : (rShifted < 0 ? 0 : rShifted) + gShifted = gShifted > 1 ? 1 : (gShifted < 0 ? 0 : gShifted) + bShifted = bShifted > 1 ? 1 : (bShifted < 0 ? 0 : bShifted) + + + return CategoryColor(r: rShifted, g: gShifted, b: bShifted) + } + + // isEqualTo: determina si dos instancias de CategoryModel son iguales, sin considerar sus ids. + func isEqualTo(_ catModel: CategoryModel) -> Bool { + return name == catModel.name && color.isEqualTo(catModel.color) + } + + static func ==(lhs: CategoryModel, rhs: CategoryModel) -> Bool { + var result: Bool = true + result = result && lhs.id == rhs.id + result = result && lhs.name == rhs.name + result = result && lhs.color.isEqualTo(rhs.color) + return result + } + + static func <(lhs: CategoryModel, rhs: CategoryModel) -> Bool { + return lhs.name.lowercased() < rhs.name.lowercased() + } + + static func >(lhs: CategoryModel, rhs: CategoryModel) -> Bool { + return lhs.name.lowercased() > rhs.name.lowercased() + } + + static func <=(lhs: CategoryModel, rhs: CategoryModel) -> Bool { + return lhs.name.lowercased() <= rhs.name.lowercased() + } + + static func >=(lhs: CategoryModel, rhs: CategoryModel) -> Bool { + return lhs.name.lowercased() >= rhs.name.lowercased() + } + + static func newEmptyCategory() -> CategoryModel { + return CategoryModel(name: "", color: CategoryColor(r: 0.9, g: 0.9, b: 0.9)) + } +} + +struct CategoryColor: Codable { + var r: Double // 0...1 + var g: Double // 0...1 + var b: Double // 0...1 + + init(r: Double, g: Double, b: Double){ + self.r = CategoryColor.normalizeColorValue(r) + self.g = CategoryColor.normalizeColorValue(g) + self.b = CategoryColor.normalizeColorValue(b) + } + + func isValidCategoryColor() -> Bool { + if r < 0 || r > 1 { + return false + } else if g < 0 || g > 1 { + return false + } else if b < 0 || b > 1 { + return false + } + return true + } + + func isEqualTo(_ catColorModel: CategoryColor) -> Bool { + return r == catColorModel.r && g == catColorModel.g && b == catColorModel.b + } + + func isSimilarTo(_ catColorModel: CategoryColor, range: Double) -> Bool { + let _r = catColorModel.r + let _g = catColorModel.g + let _b = catColorModel.b + + if r > CategoryColor.normalizeColorValue(_r + range) || r < CategoryColor.normalizeColorValue(_r - range) { + return false + } else if g > CategoryColor.normalizeColorValue(_g + range) || g < CategoryColor.normalizeColorValue(_g - range) { + return false + } else if b > CategoryColor.normalizeColorValue(_b + range) || b < CategoryColor.normalizeColorValue(_b - range) { + return false + } + + return true + } + + func isBright() -> Bool { + return ColorMaker.colorLuminance(catColor: self) >= 0.6 + } + + static func normalizeColorValue(_ colorValue: Double) -> Double { + return colorValue > 1 ? 1 : (colorValue < 0 ? 0 : colorValue) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Models/PictogramModel.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Models/PictogramModel.swift new file mode 100644 index 0000000..0300d73 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Models/PictogramModel.swift @@ -0,0 +1,63 @@ +// +// PictogramModel.swift +// Comunicador +// +// Created by emilio on 23/05/23. +// + +import Foundation +import FirebaseFirestoreSwift + +struct PictogramModel: Identifiable, Codable, Comparable { + @DocumentID var id: String? + var name: String + var imageUrl: String + var categoryId: String + + func isValidPictogram() -> Bool { + if name.isEmpty || name.count > 20 { // Definir número máximo de caracteres. + return false + } else if categoryId.isEmpty { + return false + } + return true + } + + // isEqualTo: determina si dos instancias de PictogramModel son iguales, sin considerar sus ids. + func isEqualTo(_ pictoModel: PictogramModel) -> Bool { + return name == pictoModel.name && categoryId == pictoModel.categoryId + } + + static func ==(lhs: PictogramModel, rhs: PictogramModel) -> Bool { + var result: Bool = true + result = result && lhs.id == rhs.id + result = result && lhs.name == rhs.name + result = result && lhs.imageUrl == rhs.imageUrl + result = result && lhs.categoryId == rhs.categoryId + return result + } + + static func <(lhs: PictogramModel, rhs: PictogramModel) -> Bool { + return lhs.name.lowercased() < rhs.name.lowercased() + } + + static func >(lhs: PictogramModel, rhs: PictogramModel) -> Bool { + return lhs.name.lowercased() > rhs.name.lowercased() + } + + static func <=(lhs: PictogramModel, rhs: PictogramModel) -> Bool { + return lhs.name.lowercased() <= rhs.name.lowercased() + } + + static func >=(lhs: PictogramModel, rhs: PictogramModel) -> Bool { + return lhs.name.lowercased() >= rhs.name.lowercased() + } + + static func newEmptyPictogram(catId: String? = nil) -> PictogramModel { + return PictogramModel( + name: "", + imageUrl: "", + categoryId: catId ?? "" + ) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/CategoryViewModel.swift b/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/CategoryViewModel.swift new file mode 100644 index 0000000..8b3b5bc --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/CategoryViewModel.swift @@ -0,0 +1,242 @@ +// +// CategoryViewModel.swift +// Comunicador +// +// Created by emilio on 25/05/23. +// + +import Foundation +import Firebase +import FirebaseFirestore + +enum CatError: Error, LocalizedError { + case doesNotExist + + var errorDescription: String? { + switch self { + case .doesNotExist: + return NSLocalizedString("Error: la categoría a editar no existe", comment: "") + } + } +} + +// CategoryViewModel: interface that provides an access to a Firestore collection of categories, found in +// in the path given in the parameter 'collectionPath' of the class' initializer. +class CategoryViewModel: ObservableObject { + @Published var categories: [String:CategoryModel] = [:] // [categoryId:CategoryModel] + private var sortedCategories: SortedArray = SortedArray() + private var listenerHandle: ListenerRegistration? = nil + private var catCollection: CollectionReference + + init(collectionPath: String){ + catCollection = Firestore.firestore().collection(collectionPath) + + listenerHandle = catCollection.addSnapshotListener { snap, error in + if let error = error { + // Failure. + print(error.localizedDescription) + return + } + + if let docChanges: [DocumentChange] = snap?.documentChanges { + for docChange in docChanges { + if let catModel: CategoryModel = try? docChange.document.data(as: CategoryModel.self){ + self.updateCat(catModel: catModel, changeType: docChange.type) + } else { + // Failure: unsuccessful mapping. + } + } + } else { + // Failure: snap (QuerySnapshot) has no value (nil). + } + } + } + + // updateCat: auxiliary function, handles a document change in the observed collection. + private func updateCat(catModel: CategoryModel, changeType: DocumentChangeType) -> Void { + switch changeType { + case .added: + self.categories[catModel.id!] = catModel + self.sortedCategories.add(item: catModel) + case .modified: + self.sortedCategories.updateWith(item: categories[catModel.id!]!, with: catModel) + self.categories[catModel.id!] = catModel + case .removed: + self.sortedCategories.remove(item: self.categories[catModel.id!]!) + self.categories.removeValue(forKey: catModel.id!) + } + } + + func getCat(catId: String) -> CategoryModel? { + return self.categories[catId] + } + + // getFirstCat: returns the first category of the stored collection of categories. + func getFirstCat() -> CategoryModel? { + return self.getCats().first + } + + func getCats() -> [CategoryModel] { + return sortedCategories.getItems() + } + + func getCats(nameFilter: String) -> [CategoryModel] { + let cleanedNameFilter: String = nameFilter.cleanForSearch() + + if nameFilter.isEmpty { + return sortedCategories.getItems() + } else { + return sortedCategories.getItems().filter { catModel in + return catModel.name.lowercased().contains(cleanedNameFilter) + } + } + } + + func getCatsWithSimilarColor(catModel: CategoryModel, range: Double = 0.2) -> [CategoryModel] { + return Array(self.categories.values).filter{ + $0.color.isSimilarTo(catModel.color, range: range) && (catModel.id != nil ? $0.id! != catModel.id! : true) + } + } + + func addCat(name: String, color: (r: Double, g: Double, b: Double), completition: @escaping (Error?, String?)->Void) -> Void { + let catModel: CategoryModel = CategoryModel(name: name, color: CategoryColor(r: color.r, g: color.g, b: color.b)) + var docRef: DocumentReference? + + do { + docRef = try self.catCollection.addDocument(from: catModel) {error in + if let error = error { + // Failure: unable to add cateogry to the collection. + completition(error, nil) + } else { + // Adición exitosa. + completition(nil, docRef?.documentID) + } + } + } catch let error { + // Failure: unable to add document to the collection. + completition(error, nil) + } + } + + func addCat(catModel: CategoryModel, completition: @escaping (Error?, String?)->Void) -> Void { + var docRef: DocumentReference? + + do { + docRef = try self.catCollection.addDocument(from: catModel) {error in + if let error = error { + // Failure: unable to add document to the collection. + completition(error, nil) + } else { + // Success. + completition(nil, docRef?.documentID) + } + } + } catch let error { + // Failure: unable to add document to the collection. + completition(error, nil) + } + } + + func removeCat(catId: String, completition: @escaping (Error?)->Void) -> Void { + if self.categories[catId] == nil { + completition(CatError.doesNotExist) + return + } + + self.catCollection.document(catId).delete(){ error in + if let error = error { + // Failure: unable to remove document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } + + func editCat(catId: String, name: String, color: (r: Double, g: Double, b: Double), completition: @escaping (Error?)->Void) -> Void { + if self.categories[catId] == nil { + completition(CatError.doesNotExist) + return + } + + let catModel: CategoryModel = CategoryModel(name: name, color: CategoryColor(r: color.r, g: color.g, b: color.b)) + + do { + try self.catCollection.document(catId).setData(from: catModel) { error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Succcess. + completition(nil) + } + } + } catch let error { + // Failure: unable to edit document from the collection. + completition(error) + } + } + + func editCat(catId: String, catModel: CategoryModel, completition: @escaping (Error?)->Void) -> Void { + if self.categories[catId] == nil { + completition(CatError.doesNotExist) + return + } + + do { + try self.catCollection.document(catId).setData(from: catModel) { error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } catch let error { + // Failure: unable to edit document from the collection. + completition(error) + } + } + + func editCatName(catId: String, name: String, completition: @escaping (Error?)->Void) -> Void { + if self.categories[catId] == nil { + completition(CatError.doesNotExist) + return + } + + self.catCollection.document(catId).updateData(["name": name]){ error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } + + func editCatColor(catId: String, color: (r: Double, g: Double, b: Double), completition: @escaping (Error?)->Void) -> Void { + if self.categories[catId] == nil { + completition(CatError.doesNotExist) + return + } + + let colorDict: [String:Double] = ["r": color.r, "g": color.g, "b": color.b] + self.catCollection.document(catId).updateData(["color": colorDict]){ error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } + + func stopListening() -> Void { + self.listenerHandle?.remove() + } + +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/PictogramViewModel.swift b/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/PictogramViewModel.swift new file mode 100644 index 0000000..3135b6a --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/ViewModels/PictogramViewModel.swift @@ -0,0 +1,260 @@ +// +// PictogramViewModel.swift +// Comunicador +// +// Created by emilio on 23/05/23. +// + +import Foundation +import Firebase +import FirebaseFirestore + +enum PictoError: Error, LocalizedError { + case doesNotExist + + var errorDescription: String? { + switch self { + case .doesNotExist: + return NSLocalizedString("Error: el pictograma o la categoría no existe", comment: "") + } + } +} + +enum PictogramProperty: String { + case name = "name" + case imageUrl = "imageUrl" + case categoryId = "categoryId" +} + +// PictogramViewModel: interface that provides an access to a Firestore collection of pictograms, found in +// in the path given in the parameter 'collectionPath' of the class' initializer. +class PictogramViewModel: ObservableObject { + @Published private var pictograms: [String:PictogramModel] = [:] // [pictogramId:PictogramModel] + private var categoryMap: [String:SortedArray] = [:] // [categoryId:SortedPictoArray] + private var listenerHandle: ListenerRegistration? = nil + private var pictoCollection: CollectionReference + + init(collectionPath: String){ + pictoCollection = Firestore.firestore().collection(collectionPath) + + listenerHandle = pictoCollection.addSnapshotListener { snap, error in + if let error = error { + // Failure. + print(error.localizedDescription) + return + } + + if let docChanges: [DocumentChange] = snap?.documentChanges { + for docChange in docChanges { + if let pictoModel: PictogramModel = try? docChange.document.data(as: PictogramModel.self){ + self.updatePicto(pictoModel: pictoModel, changeType: docChange.type) + } else { + // Failure: unsuccessful mapping. + } + } + } else { + // Failure: snap (QuerySnapshot) has no value (nil). + } + } + } + + // updatePicto: auxiliary function, handles a document change in the observed collection. + private func updatePicto(pictoModel: PictogramModel, changeType: DocumentChangeType) -> Void { + switch changeType { + case .added: + self.pictograms[pictoModel.id!] = pictoModel + if self.categoryMap[pictoModel.categoryId] == nil { + self.categoryMap[pictoModel.categoryId] = SortedArray() + } + self.categoryMap[pictoModel.categoryId]!.add(item: pictoModel) + + case .removed: + if self.pictograms[pictoModel.id!] != nil { + self.pictograms.removeValue(forKey: pictoModel.id!) + self.categoryMap[pictoModel.categoryId]!.remove(item: pictoModel) + } + case .modified: + let beforeChangePictoModel: PictogramModel = self.pictograms[pictoModel.id!]! + self.pictograms[pictoModel.id!] = pictoModel + + if pictoModel.categoryId != beforeChangePictoModel.categoryId { + self.categoryMap[beforeChangePictoModel.categoryId]!.remove(item: beforeChangePictoModel) + + if self.categoryMap[pictoModel.categoryId] == nil { + self.categoryMap[pictoModel.categoryId] = SortedArray() + } + self.categoryMap[pictoModel.categoryId]!.add(item: pictoModel) + } else { + self.categoryMap[pictoModel.categoryId]!.updateWith(item: beforeChangePictoModel, with: pictoModel) + } + } + } + + // getPictosFromCat: returns an array of PictogramModels whose category has as id 'catId'. + func getPictosFromCat(catId: String) -> [PictogramModel] { + if self.categoryMap[catId] != nil { + return self.categoryMap[catId]!.getItems() + } + return [] + } + + // getPictosFromCat: returns an array of PictogramModels whose category has as id 'catId'. + // The corresponding models are then filtered by name, taking only those that contain 'nameFilter'. + func getPictosFromCat(catId: String, nameFilter: String) -> [PictogramModel] { + let cleanedNameFilter: String = nameFilter.cleanForSearch() + + if self.categoryMap[catId] != nil { + let pictoModels: [PictogramModel] = self.categoryMap[catId]!.getItems() + + if cleanedNameFilter.isEmpty { + return pictoModels + } else { + return pictoModels.filter { + $0.name.lowercased().contains(cleanedNameFilter) + } + } + } + return [] + } + + func getNumPictosInCat(catId: String) -> Int { + if self.categoryMap[catId] != nil { + return self.categoryMap[catId]!.count + } + return 0 + } + + func addPicto(name: String, imageUrl: String, cateogryId: String, completition: @escaping (Error?)->Void) -> Void { + let pictoModel: PictogramModel = PictogramModel(name: name, + imageUrl: imageUrl, + categoryId: cateogryId) + + do { + _ = try self.pictoCollection.addDocument(from: pictoModel){ error in + if let error = error { + // Failure: unable to add document to the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } catch let error { + // Failure: unable to add document to the collection. + completition(error) + } + } + + func addPicto(pictoModel: PictogramModel, completition: @escaping (Error?)->Void) -> Void { + do { + _ = try self.pictoCollection.addDocument(from: pictoModel){ error in + if let error = error { + // Failure: unable to add document to the collection. + completition(error) + } else { + completition(nil) + } + } + } catch let error { + // Failure: unable to add document to the collection. + completition(error) + } + } + + func removePicto(pictoId: String, completition: @escaping (Error?)->Void) -> Void { + self.pictoCollection.document(pictoId).delete() { error in + if let error = error { + // Failure: unable to remove document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } + + func removeAllPictosFrom(catId: String, completition: @escaping (Error?)->Void) -> Void { + if categoryMap[catId] != nil { + let batch: WriteBatch = Firestore.firestore().batch() + + for pictoModel: PictogramModel in categoryMap[catId]!.getItems() { + batch.deleteDocument(pictoCollection.document(pictoModel.id!)) + } + + batch.commit() { error in + completition(error) + } + } else { + completition(PictoError.doesNotExist) + } + } + + func editPicto(pictoId: String, name: String, imageUrl: String, cateogryId: String, completition: @escaping (Error?)->Void) -> Void { + if self.pictograms[pictoId] == nil { + completition(PictoError.doesNotExist) + return // hola + } + + let pictoModel: PictogramModel = PictogramModel(name: name, + imageUrl: imageUrl, + categoryId: cateogryId) + + do { + try self.pictoCollection.document(pictoId).setData(from: pictoModel) { error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } catch let error { + // Failure: unable to edit document from the collection. + completition(error) + } + } + + func editPicto(pictoId: String, pictoModel: PictogramModel, completition: @escaping (Error?)->Void) -> Void { + if self.pictograms[pictoId] == nil { + completition(PictoError.doesNotExist) + return + } + + do { + try self.pictoCollection.document(pictoId).setData(from: pictoModel) { error in + if let error = error { + // Failure: unable to edit document from the collection. + completition(error) + } else { + // Success. + completition(nil) + } + } + } catch let error { + // Failure: unable to edit document from the collection. + completition(error) + } + } + + func editPictoProperty(pictoId: String, property: PictogramProperty, value: String, completition: @escaping (Error?)->Void) -> Void { + if self.pictograms[pictoId] == nil { + completition(PictoError.doesNotExist) + return + } + + self.pictoCollection.document(pictoId).updateData([property.rawValue: value]){ error in + if let error = error { + // Failure: unable to edit document property. + completition(error) + } else { + // Success. + completition(nil) + } + } + } + + func stopListening() -> Void { + self.listenerHandle?.remove() + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryEditorWindowView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryEditorWindowView.swift new file mode 100644 index 0000000..dc65bcb --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryEditorWindowView.swift @@ -0,0 +1,220 @@ +// +// CategoryEditorWindowView.swift +// Comunicador +// +// Created by emilio on 04/06/23. +// + +import SwiftUI + +struct CategoryEditorWindowView: View { + @State var catModel: CategoryModel + let catModelCapture: CategoryModel + let isNewCat: Bool + @State var isDeletingCat: Bool = false + + @ObservedObject var pictoVM: PictogramViewModel + @ObservedObject var catVM: CategoryViewModel + + @Binding var pickedCategoryId: String + @Binding var searchText: String + + @State var showErrorMessage: Bool = false + + @State var DBActionInProgress: Bool = false + + @Environment(\.dismiss) var dismiss + + init(catModel: CategoryModel, pictoVM: PictogramViewModel, catVM: CategoryViewModel, pickedCategoryId: Binding, searchText: Binding){ + self.isNewCat = catModel.id == nil + self._catModel = State(initialValue: catModel) + self.catModelCapture = catModel + self.pictoVM = pictoVM + self.catVM = catVM + self._pickedCategoryId = pickedCategoryId + self._searchText = searchText + } + + var body: some View { + let catsWithSimilarColor: [CategoryModel] = catVM.getCatsWithSimilarColor(catModel: catModel) + + GeometryReader { geo in + VStack(alignment: .leading, spacing: 20){ + HStack { + Spacer() + + Text(isNewCat ? "Nueva Categoría" : catModelCapture.name) + .font(.system(size: 35, weight: .bold)) + .padding() + .foregroundColor(ColorMaker.buildforegroundTextColor(catColor: catModelCapture.color)) + .background(catModelCapture.buildColor()) + .cornerRadius(10) + + Spacer() + } + .overlay(alignment: .leading) { + if !isNewCat { + let ovColor: Color = Color(red: 0.5, green: 0, blue: 0) + LongPressButtonWithImage(text: "Eliminar", width: 110, background: .red, overlayedBackground: ovColor, systemNameImage: "trash") { + isDeletingCat = true + } + } + } + + VStack(alignment: .leading) { + Text("Nombre" + (catModel.name == catModelCapture.name ? "" : " *")) + .font(.system(size: 20, weight: .bold)) + + TextFieldView(fieldWidth: geo.size.width * 0.4, placeHolder: "Nombre de la categoría", inputText: $catModel.name) + } + + VStack(alignment: .leading) { + HStack(spacing: 10) { + Text("Color" + (catModel.color.isEqualTo(catModelCapture.color) ? "" : " *")) + .font(.system(size: 20, weight: .bold)) + + if catsWithSimilarColor.count > 0 { + HStack(spacing: 5) { + let similarCatModel: CategoryModel = catsWithSimilarColor.first! + Text("La categoría") + Text("\(similarCatModel.name)") + .background(similarCatModel.buildColor()) + .foregroundColor(ColorMaker.buildforegroundTextColor(catColor: similarCatModel.color)) + Text("tiene un color similar.") + } + } + } + + ColorPickerView(red: $catModel.color.r, green: $catModel.color.g, blue: $catModel.color.b) + .frame(height: 280) + } + + HStack { + //Cancel + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + let addButtonIsDisabled: Bool = !catModel.isValidCateogry() || catModel.isEqualTo(catModelCapture) || catsWithSimilarColor.count > 0 + //Save + Button(action: { + DBActionInProgress = true + if isNewCat { + catVM.addCat(catModel: catModel){ error, docId in + if error != nil { + showErrorMessage = true + } else { + searchText = "" + pickedCategoryId = docId ?? "" + dismiss() + } + } + } else { + catVM.editCat(catId: catModel.id!, catModel: catModel){ error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } + + }) { + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .disabled(addButtonIsDisabled) + .allowsHitTesting(!DBActionInProgress) + + + /* + + ButtonWithImageView(text: "Cancelar", systemNameImage: "xmark.circle.fill", background: .gray) { + dismiss() + } + + + + ButtonWithImageView(text: "Guardar", systemNameImage: "arrow.right.circle.fill", isDisabled: addButtonIsDisabled){ + DBActionInProgress = true + if isNewCat { + catVM.addCat(catModel: catModel){ error, docId in + if error != nil { + showErrorMessage = true + } else { + searchText = "" + pickedCategoryId = docId ?? "" + dismiss() + } + } + } else { + catVM.editCat(catId: catModel.id!, catModel: catModel){ error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } + } + .allowsHitTesting(!DBActionInProgress) + + Spacer() + */ + } + } + .padding(.horizontal, 50) + .padding(.vertical, 50) + .frame(width: geo.size.width, height: geo.size.height) + .background(.white) + } + .customAlert(title: "Error", message: "Error", isPresented: $showErrorMessage) + .customConfirmAlert(title: "Confirmar Eliminación", message: "La categoría y sus pictogramas serán eliminados para siempre.", isPresented: $isDeletingCat) { + var pictoDeletionSucceeded: Bool = true + + if pictoVM.getNumPictosInCat(catId: catModel.id!) > 0 { + pictoVM.removeAllPictosFrom(catId: catModel.id!) { error in + if error != nil { + // Los pictogramas de la categoría a eliminar no fueron eliminados. + pictoDeletionSucceeded = false + } + } + } + + if pictoDeletionSucceeded { + catVM.removeCat(catId: catModel.id!) { error in + if error != nil { + // Los pictogramas de la categoría fueron eliminados, pero la categoría en sí no. + showErrorMessage = true + } else { + pickedCategoryId = catVM.getFirstCat()?.id! ?? "" + dismiss() + } + } + } else { + showErrorMessage = true + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryPickerView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryPickerView.swift new file mode 100644 index 0000000..19c6673 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/CategoryPickerView.swift @@ -0,0 +1,68 @@ +// +// CategoryPickerView.swift +// Comunicador +// +// Created by emilio on 28/05/23. +// + +import SwiftUI + +struct CategoryPickerView: View { + var categoryModels: [CategoryModel] + @Binding var pickedCategoryId: String + @Binding var userHasChosenCat: Bool + + var body: some View { + MarkedScrollView(scrollDirection: .horizontal) { + HStack(spacing: 11){ + ForEach(categoryModels) { catModel in + ZStack { + let catIsSelected: Bool = catModel.id! == pickedCategoryId + + CategoryButtonTextView(catName: catModel.name, foregroundColor: Color.clear, background: catModel.buildColor()) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .scaleEffect(y: catIsSelected ? 2 : 1, anchor: .top) + + CategoryButtonTextView(catName: catModel.name, foregroundColor: ColorMaker.buildforegroundTextColor(catColor: catModel.color), background: catModel.buildColor()) + .overlay(alignment: .top) { + Rectangle() + .foregroundColor(catModel.buildColor(colorShift: catModel.color.isBright() ? -0.15 : 0.15)) + .frame(height: 5) + .opacity(catIsSelected ? 1 : 0) + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .onTapGesture { + pickedCategoryId = catModel.id! + + if !userHasChosenCat { + userHasChosenCat = true + } + } + } + } + .frame(maxHeight: .infinity) + } + .overlay(alignment: .leading) { + if categoryModels.isEmpty { + Text("No hay categorías") + .bold() + } + } + } +} + +struct CategoryButtonTextView: View { + let catName: String + let foregroundColor: Color + let background: Color + + var body: some View { + Text(catName) + .frame(minWidth: 60) + .padding() + .bold() + .foregroundColor(foregroundColor) + .background(background) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/DropDownCategoryPicker.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/DropDownCategoryPicker.swift new file mode 100644 index 0000000..1bcc9c0 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/DropDownCategoryPicker.swift @@ -0,0 +1,89 @@ +// +// DropDownCategoryPicker.swift +// Comunicador +// +// Created by emilio on 31/05/23. +// + +import SwiftUI + +struct DropDownCategoryPicker: View { + var categoryModels: [CategoryModel] + @Binding var pickedCategoryId: String + @State var pickedCatModel: CategoryModel? + var itemWidth: CGFloat + var itemHeight: CGFloat + var textSize: Double = 15 + + @State private var listWindowHeight: CGFloat = 0 + + init(categoryModels: [CategoryModel], pickedCategoryId: Binding, pickedCatModel: CategoryModel?, itemWidth: CGFloat = 150, itemHeight: CGFloat = 60){ + self.categoryModels = categoryModels + _pickedCategoryId = pickedCategoryId + _pickedCatModel = State(initialValue: pickedCatModel) + self.itemWidth = itemWidth + self.itemHeight = itemHeight + } + + var body: some View { + let listWindowHeightExpanded: CGFloat = itemHeight * 3.9 + let bgColor: Color = Color(red: 0.9, green: 0.9, blue: 0.9) + + Button { + withAnimation { + listWindowHeight = listWindowHeight == 0 ? listWindowHeightExpanded : 0 + } + } label: { + HStack(spacing: 15){ + Rectangle() + .frame(width: 10, height: itemHeight) + .foregroundColor(pickedCatModel == nil ? bgColor : pickedCatModel!.buildColor()) + Text(pickedCatModel == nil ? "Categoría" : pickedCatModel!.name) + .font(.system(size: textSize, weight: .bold)) + .foregroundColor(.black) + Spacer() + Image(systemName: listWindowHeight == 0 ? "chevron.up" : "chevron.down") + } + .padding(.trailing, 10) + .frame(width: itemWidth, height: itemHeight) + .background(bgColor) + .cornerRadius(5) + } + .overlay(alignment: .top){ + if listWindowHeight != 0 { + MarkedScrollView(scrollDirection: .vertical) { + VStack(spacing: 0){ + ForEach(categoryModels){ catModel in + Button { + pickedCategoryId = catModel.id! + pickedCatModel = catModel + withAnimation { + listWindowHeight = listWindowHeight == 0 ? listWindowHeightExpanded : 0 + } + } label: { + HStack { + Text(catModel.name) + .font(.system(size: textSize, weight: .bold)) + .foregroundColor(ColorMaker.buildforegroundTextColor(catColor: catModel.color)) + Spacer() + if catModel.id! == pickedCategoryId { + Image(systemName: "checkmark") + .foregroundColor(ColorMaker.buildforegroundTextColor(catColor: catModel.color)) + } + } + .padding() + .frame(width: itemWidth, height: itemHeight) + .background(catModel.buildColor()) + } + } + } + } + .frame(height: listWindowHeight) + .background(bgColor) + .cornerRadius(5) + .offset(y: (listWindowHeightExpanded * -1) - 10) + .animation(.easeIn, value: listWindowHeight) + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramEditorWindowView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramEditorWindowView.swift new file mode 100644 index 0000000..73fd1c8 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramEditorWindowView.swift @@ -0,0 +1,229 @@ +// +// PictogramEditorWindowView.swift +// Comunicador +// +// Created by emilio on 31/05/23. +// + +import SwiftUI + +func buildImageName(catName: String, pictoName: String) -> String { + let newCatName = catName.replacingOccurrences(of: " ", with: "") + let newPictoName = pictoName.replacingOccurrences(of: " ", with: "") + + return newCatName + "_" + newPictoName + "_" + UUID().uuidString +} + +struct PictogramEditorWindowView: View { + @State var pictoModel: PictogramModel + let pictoModelCapture: PictogramModel + let isNewPicto: Bool + @State var isDeletingPicto: Bool = false + + @ObservedObject var pictoVM: PictogramViewModel + @ObservedObject var catVM: CategoryViewModel + + @State var showErrorMessage: Bool = false + + @State private var showImagePicker: Bool = false + @State var temporaryUIImage: UIImage? = nil + var imageHandler: FirebaseAlmacenamiento = FirebaseAlmacenamiento() + + @State var DBActionInProgress: Bool = false + + @Environment(\.dismiss) var dismiss + + init(pictoModel: PictogramModel, pictoVM: PictogramViewModel, catVM: CategoryViewModel){ + self.isNewPicto = pictoModel.id == nil + self._pictoModel = State(initialValue: pictoModel) + self.pictoModelCapture = pictoModel + self.pictoVM = pictoVM + self.catVM = catVM + } + + var body: some View { + let currCat: CategoryModel? = catVM.getCat(catId: pictoModel.categoryId) + + GeometryReader { geo in + VStack(spacing: 30) { + HStack { + Spacer() + + Text(isNewPicto ? "Nuevo Pictograma" : pictoModelCapture.name) + .font(.system(size: 35, weight: .bold)) + + Spacer() + } + .overlay(alignment: .leading) { + if !isNewPicto { + ButtonWithImageView(text: "Eliminar", systemNameImage: "trash", background: .red) { + isDeletingPicto = true + } + } + + } + + HStack(spacing: 30) { + PictogramView(pictoModel: $pictoModel.wrappedValue, catModel: currCat ?? CategoryModel.newEmptyCategory(), displayName: true, displayCatColor: true, temporaryUIImage: temporaryUIImage) + .frame(width: geo.size.width * 0.4, height: geo.size.width * 0.4) + + VStack(alignment: .leading) { + Text("Nombre" + (pictoModel.name == pictoModelCapture.name ? "" : "*")) + .font(.system(size: 20, weight: .bold)) + + TextFieldView(fieldWidth: geo.size.width * 0.3, placeHolder: "Nombre", inputText: $pictoModel.name) + + Text("Imagen" + (temporaryUIImage == nil ? "" : "*")) + .font(.system(size: 20, weight: .bold)) + Button { + showImagePicker.toggle() + } label: { + HStack { + Image(systemName: temporaryUIImage == nil ? "arrow.up.square" : "checkmark") + .foregroundColor(temporaryUIImage == nil ? .white : .green) + Text(temporaryUIImage == nil ? "Subir una imagen" : "Subir otra imagen") + .bold() + .foregroundColor(.white) + } + .padding() + .frame(height: 50) + .background(.blue) + .cornerRadius(10) + } + + if !isNewPicto { + Text("Categoría" + (pictoModel.categoryId == pictoModelCapture.categoryId ? "" : "*")) + .font(.system(size: 20, weight: .bold)) + + DropDownCategoryPicker(categoryModels: catVM.getCats(), pickedCategoryId: $pictoModel.categoryId, pickedCatModel: catVM.getCat(catId: pictoModel.categoryId), itemWidth: geo.size.width * 0.3) + } + } + } + + HStack { + + Button(action: { + dismiss() + }){ + HStack { + Text("Cancelar") + .font(.headline) + + Spacer() + Image(systemName: "xmark.circle.fill") + } + } + .padding() + .background(Color.gray) + .cornerRadius(10) + .foregroundColor(.white) + + // Save + let saveButtonIsDisabled: Bool = !(pictoModel.isValidPictogram() && !pictoModel.isEqualTo(pictoModelCapture) && (temporaryUIImage != nil || !pictoModel.imageUrl.isEmpty)) + + Button(action: { + DBActionInProgress = true + Task { + if temporaryUIImage != nil { + let imageName: String = buildImageName(catName: catVM.getCat(catId: pictoModel.categoryId)!.name, pictoName: pictoModel.name) + if let downloadUrl: URL = await imageHandler.uploadImage(image: temporaryUIImage!, name: imageName){ + self.pictoModel.imageUrl = downloadUrl.absoluteString + } else { + // Error al subir imagen. + } + } + + if isNewPicto { + pictoVM.addPicto(pictoModel: self.pictoModel) {error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } else { + pictoVM.editPicto(pictoId: self.pictoModel.id!, pictoModel: self.pictoModel) {error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } + } + }) { + HStack { + Text("Guardar") + .font(.headline) + + Spacer() + Image(systemName: "arrow.right.circle.fill") + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .disabled(saveButtonIsDisabled) + .allowsHitTesting(!DBActionInProgress) + /* + //Cancel + ButtonWithImageView(text: "Cancelar", systemNameImage: "xmark.circle.fill", background: .gray) { + dismiss() + } + + + ButtonWithImageView(text: "Guardar", systemNameImage: "arrow.right.circle.fill", isDisabled: saveButtonIsDisabled) { + DBActionInProgress = true + Task { + if temporaryUIImage != nil { + let imageName: String = buildImageName(catName: catVM.getCat(catId: pictoModel.categoryId)!.name, pictoName: pictoModel.name) + if let downloadUrl: URL = await imageHandler.uploadImage(image: temporaryUIImage!, name: imageName){ + self.pictoModel.imageUrl = downloadUrl.absoluteString + } else { + // Error al subir imagen. + } + } + + if isNewPicto { + pictoVM.addPicto(pictoModel: self.pictoModel) {error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } else { + pictoVM.editPicto(pictoId: self.pictoModel.id!, pictoModel: self.pictoModel) {error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } + } + } + .allowsHitTesting(!DBActionInProgress) + */ + } + } + .padding(.horizontal, 70) + .padding(.vertical, 50) + .frame(width: geo.size.width, height: geo.size.height) + .fullScreenCover(isPresented: $showImagePicker, onDismiss: nil) { + ImagePicker(image: $temporaryUIImage) + } + } + .customAlert(title: "Error", message: "Error", isPresented: $showErrorMessage) // Definir error + .customConfirmAlert(title: "Confirmar Eliminación", message: "El pictograma será eliminado para siempre.", isPresented: $isDeletingPicto) { + pictoVM.removePicto(pictoId: pictoModel.id!) { error in + if error != nil { + showErrorMessage = true + } else { + dismiss() + } + } + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridArrowView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridArrowView.swift new file mode 100644 index 0000000..1c61060 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridArrowView.swift @@ -0,0 +1,49 @@ +// +// PictogramGridArrowView.swift +// nuevoamanecer +// +// Created by emilio on 30/08/23. +// + +import SwiftUI + +struct PictogramGridArrowView: View { + var systemName: String + var isDisabled: Bool + var arrowAction: () -> Void + + let colors: [String:Color] = [ + "disabledArrow": Color(red: 0.92, green: 0.92, blue: 0.92), + // "disabledArrow": Color(red: 0.5, green: 0.5, blue: 0.5), + "disabledBackground": Color(red: 0.97, green: 0.97, blue: 0.97), + "arrow": Color(red: 0.65, green: 0.65, blue: 0.65), + "background": Color(red: 0.75, green: 0.75, blue: 0.75) + ] + + var body: some View { + GeometryReader { geo in + VStack { + Spacer() + + Button(action: { + arrowAction() + }) { + VStack { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(10) + .foregroundColor(isDisabled ? colors["disabledArrow"] : colors["arrow"]) + } + .frame(width: geo.size.width * 0.7, height: geo.size.height * 0.6) + .background(isDisabled ? colors["disabledBackground"] : colors["background"]) + .cornerRadius(30) + } + .allowsHitTesting(!isDisabled) + + Spacer() + } + .frame(width: geo.size.width, height: geo.size.height) + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridView.swift new file mode 100644 index 0000000..152db62 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramGridView.swift @@ -0,0 +1,115 @@ +// +// PictogramGridView.swift +// Comunicador +// +// Created by emilio on 27/05/23. +// + +import SwiftUI + +struct PictogramGridView: View { + let pictograms: [PictogramView] + let pictoWidth: CGFloat + let pictoHeight: CGFloat + var handlePictogramAddition: (() -> Void)? = nil + + @State private var currPage: Int = 1 + + var body: some View { + let editingPictograms: Bool = handlePictogramAddition != nil + + GeometryReader { geo in + let gridWidth: CGFloat = geo.size.width * (geo.size.height > geo.size.width ? 0.7 : 0.8) + let gridHeight: CGFloat = geo.size.height * 0.95 + let gridHorSpacing: Double = 25 + let gridVerSpacing: Double = 25 + let _numColumns: Int = Int((gridWidth + gridHorSpacing) / (pictoWidth + gridHorSpacing)) + let _numRows: Int = Int((gridHeight + gridVerSpacing) / (pictoHeight + gridVerSpacing)) + let numColumns: Int = _numColumns == 0 ? 1 : _numColumns + let numRows: Int = _numRows == 0 ? 1 : _numRows + let numGridItems: Int = numColumns * numRows + let numPages: Int = ceiling(pictograms.count, numGridItems) == 0 ? 1 : ceiling(pictograms.count, numGridItems) + + HStack { + Spacer() + + PictogramGridArrowView(systemName: "arrow.left", isDisabled: realCurrPage(numPages: numPages) == 1) { + currPage = realCurrPage(numPages: numPages) + currPage = currPage - 1 + } + + VStack { + Spacer() + + Grid(horizontalSpacing: 25, verticalSpacing: 25) { + ForEach(1...numRows, id: \.self){ rowNum in + GridRow { + ForEach(1...numColumns, id: \.self) { colNum in + let gridItemNumber: Int = (colNum + ((rowNum - 1) * numColumns)) + let pictoIndex: Int = ((gridItemNumber - 1) + ((realCurrPage(numPages: numPages) - 1) * numGridItems)) - (editingPictograms ? 1 : 0) + + if editingPictograms && rowNum == 1 && colNum == 1 { + AddPictogramButton(width: pictoWidth, height: pictoHeight) + .onTapGesture { + handlePictogramAddition!() + } + } else if pictoIndex < pictograms.count { + pictograms[pictoIndex] + .frame(width: pictoWidth, height: pictoHeight) + } else { + Rectangle() + .frame(width: pictoWidth, height: pictoHeight) + .foregroundColor(.gray) + .opacity(0.1) + } + } + } + } + } + .frame(width: gridWidth, height: gridHeight) + + Spacer() + } + .overlay(alignment: .bottom){ + Text("\(realCurrPage(numPages: numPages))/\(numPages)") + .font(.system(size: 20, weight: .bold, design: .default)) + .padding() + } + + PictogramGridArrowView(systemName: "arrow.right", isDisabled: realCurrPage(numPages: numPages) == numPages) { + currPage = realCurrPage(numPages: numPages) + currPage = currPage + 1 + } + + Spacer() + } + } + .background(.white) + } + + private func realCurrPage(numPages: Int) -> Int { + return currPage > numPages ? numPages : currPage + } + + private func ceiling(_ a: Int, _ b: Int) -> Int { + return Int(ceil(Double(a) / Double(b))) + } +} + +struct AddPictogramButton: View { + let width: CGFloat + let height: CGFloat + + var body: some View { + VStack { + Spacer() + Image(systemName: "plus") + .resizable() + .frame(width: 70, height: 70) + Spacer() + Text("Nuevo Pictograma") + } + .foregroundColor(.blue) + .frame(width: width, height: height) + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramSearchBarView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramSearchBarView.swift new file mode 100644 index 0000000..0030821 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramSearchBarView.swift @@ -0,0 +1,66 @@ +// +// PictogramSearchBarView.swift +// nuevoamanecer +// +// Created by emilio on 26/08/23. +// + +import SwiftUI + +struct PictogramSearchBarView: View { + @Binding var searchText: String + let searchBarWidth: Double + @Binding var searchingPicto: Bool + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(Color.gray) + .padding([.bottom, .top, .leading]) + + ZStack(alignment: .leading) { + TextField("", text: $searchText) + .frame(maxWidth: .infinity) + + HStack { + Text("Buscar") + .opacity(0.5) + .allowsHitTesting(false) + + Button { + searchingPicto.toggle() + } label: { + HStack { + Text(searchingPicto ? "pictograma" : "categoría") + .bold() + } + } + } + .opacity(searchText.isEmpty ? 1 : 0) + .allowsHitTesting(searchText.isEmpty) + + } + .padding([.bottom, .top, .trailing]) + + if !searchText.isEmpty { + Button(action: { + searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + .padding(.trailing, 20) + } + } + } + .frame(width: searchBarWidth) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(alignment: .center) { + RoundedRectangle(cornerRadius: 10) + .stroke(!searchText.isEmpty ? Color.blue : Color(.systemGray6), lineWidth: 3) + .foregroundColor(.clear) + } + } +} diff --git a/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramView.swift b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramView.swift new file mode 100644 index 0000000..b9e02f5 --- /dev/null +++ b/nuevoamanecer/PictogramSection/PictogramEditor/Views/PictogramView.swift @@ -0,0 +1,104 @@ +// +// PictogramView.swift +// Comunicador +// +// Created by emilio on 23/05/23. +// + +import SwiftUI +import Kingfisher + +// Definición de la vista del Pictograma +struct PictogramView: View { + // Variables de entrada para configurar la vista + var pictoModel: PictogramModel // Modelo del pictograma + var catModel: CategoryModel // Modelo de la categoría del pictograma + var displayName: Bool // Si se debe mostrar el nombre + var displayCatColor: Bool // Si se debe mostrar el color de la categoría + + var overlayImage: Image? = nil // Imagen a superponer + var overlayImageWidth: CGFloat = 0.2 // Ancho de la imagen a superponer + var overlayImageColor: Color = .black // Color de la imagen a superponer + var overlyImageOpacity: Double = 1 // Opacidad de la imagen a superponer + + var temporaryUIImage: UIImage? = nil // Imagen temporal a utilizar si está disponible + + var clickAction: (()->Void)? = nil + + @State var isBeingTapped: Bool = false + + // Definición del cuerpo de la vista + var pictogramBody: some View { + GeometryReader { geo in + let w: CGFloat = geo.size.width // Ancho del marco de la vista + let h: CGFloat = geo.size.height // Alto del marco de la vista + + // VStack apila las vistas verticalmente + VStack { + // Si se debe mostrar el nombre, se muestra el nombre del pictograma + if displayName { + Text(pictoModel.name.isEmpty ? "..." : pictoModel.name) + .font(.system(size: w * 0.1, weight: .bold)) + .foregroundColor(.black) + } + + // Si existe una imagen temporal, se usa, de lo contrario se descarga la imagen del pictograma + if let tempUIImage = temporaryUIImage { + Image(uiImage: tempUIImage) + .resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(10) + .foregroundColor(.gray) + } else { + KFImage(URL(string: pictoModel.imageUrl)) + .placeholder{ + Image(systemName: "photo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: geo.size.width * 0.6) + .foregroundColor(.gray) + .opacity(0.2) + } + .resizable() + .aspectRatio(contentMode: .fit) + } + } + // Padding y tamaño de la vista + .padding(.horizontal, w * 0.05) + .padding(.vertical, h * 0.05) + .frame(width: w, height: h) + .background(.white) // Fondo de la vista + .border(displayCatColor || isBeingTapped ? catModel.buildColor() : .gray, width: displayCatColor || isBeingTapped ? 7 : 1) + .overlay(alignment: .topTrailing) { + if overlayImage != nil { + overlayImage! + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(overlayImageColor) + .opacity(overlyImageOpacity) + .frame(width: geo.size.width * overlayImageWidth) + .padding(10) + } + } + } + } + + // Aplicación condicional de un gesto táctil al cuerpo del pictograma. + var body: some View { + if clickAction != nil { + pictogramBody + .gesture( + TapGesture(count: 1) + .onEnded { + isBeingTapped = true + clickAction!() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.13) { + isBeingTapped = false + } + } + ) + } else { + pictogramBody + } + } +} diff --git a/nuevoamanecer/View/AddNoteView.swift b/nuevoamanecer/View/AddNoteView.swift deleted file mode 100644 index d9cf314..0000000 --- a/nuevoamanecer/View/AddNoteView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// AddNoteView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 25/05/23. -// - -import SwiftUI - -struct AddNoteView: View { - @Environment(\.dismiss) var dismiss - @ObservedObject var notes: NotesViewModel - var patient: Patient - @State private var noteTitle: String = "" - @State private var noteContent: String = "" - @State private var showingAlert = false - @State private var alertTitle = "" - @State private var alertMessage = "" - - var body: some View { - VStack { - Text("Agregar nota") - .font(.largeTitle) - .padding(.bottom) - - Form { - Section(header: Text("Título")) { - TextField("Introduce el título de la nota", text: $noteTitle) - } - - Section(header: Text("Contenido")) { - TextEditor(text: $noteContent) - .frame(minHeight: 400) - } - } - - Button(action: { - if noteTitle.isEmpty || noteContent.isEmpty { - self.alertTitle = "Faltan campos" - self.alertMessage = "Por favor, rellena todos los campos antes de guardar la nota." - self.showingAlert = true - } else { - let newNote = Note(id: UUID().uuidString, patientId: patient.id, order: patient.notes.count + 1, title: noteTitle, text: noteContent, date: Date()) - - notes.addData(patient: patient, note: newNote) { response in - if response == "OK" { - self.alertTitle = "Nota guardada" - self.alertMessage = "La nota ha sido guardada con éxito." - self.showingAlert = false - - Task{ - if let notesList = await notes.getDataById(patientId: patient.id){ - DispatchQueue.main.async{ - self.notes.notesList = notesList.sorted { $0.order < $1.order } - dismiss() - } - } - } - - } else { - self.alertTitle = "Error" - self.alertMessage = response - self.showingAlert = true - } - } - } - }) { - Text("Guardar nota") - .frame(minWidth: 0, maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(40) - } - .padding(.horizontal) - .alert(isPresented: $showingAlert) { - Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) - } - } - .padding() - } -} - -struct AddNoteView_Previews: PreviewProvider { - static var previews: some View { - AddNoteView(notes: NotesViewModel(), patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String]())) - } -} diff --git a/nuevoamanecer/View/AddPatientView.swift b/nuevoamanecer/View/AddPatientView.swift deleted file mode 100644 index 2eeb8d5..0000000 --- a/nuevoamanecer/View/AddPatientView.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// AddPatientView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 22/05/23. -// - -import SwiftUI -struct AddPatientView: View { - - @Environment(\.dismiss) var dismiss - @ObservedObject var patients : PatientsViewModel - - let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .long - return formatter - }() - - var cognitiveLevels = ["Alto", "Medio", "Bajo"] - @State private var congnitiveLevelSelector = "" - - var communicationStyles = ["Verbal", "No-verbal", "Mixto"] - @State private var communicationStyleSelector = "" - - @State private var firstName : String = "" - @State private var lastName : String = "" - @State private var birthDate : Date = Date.now - @State private var group : String = "" - @State private var image : String = "" - - @State private var showAlert = false - - var body: some View { - VStack { - Text("Agregar Niño") - .font(.largeTitle) - .padding() - - Form { - //section for photo - - - Section(header: Text("Información del Paciente")) { - TextField("Primer Nombre", text: $firstName) - TextField("Apellidos", text: $lastName) - TextField("Grupo", text: $group) - DatePicker("Fecha de nacimiento", selection: $birthDate, displayedComponents: .date) - } - - Section(header: Text("Nivel Cognitivo")) { - Picker("Nivel Cognitivo", selection: $congnitiveLevelSelector) { - ForEach(cognitiveLevels, id: \.self) { - Text($0) - } - } - } - - Section(header: Text("Estilo de Comunicación")) { - Picker("Tipo de comunicación", selection: $communicationStyleSelector) { - ForEach(communicationStyles, id: \.self) { - Text($0) - } - } - } - - Section { - - //botón de crear usuario - Button("Agregar Niño"){ - //Checar que datos son validos - if(firstName != "" && lastName != "" && group != "" && communicationStyleSelector != "" && congnitiveLevelSelector != ""){ - let patient = Patient(id: UUID().uuidString ,firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyleSelector, cognitiveLevel: congnitiveLevelSelector, image: "http://github.com/davidmartinezhi.png", notes: [String]()) - patients.addData(patient: patient){ error in - if error != "OK" { - print(error) - }else{ - - Task { - if let patientsList = await patients.getData(){ - DispatchQueue.main.async { - self.patients.patientsList = patientsList - dismiss() - } - } - } - } - } - } - else{ - showAlert = true - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - .alert("Todos los campos deben ser llenados", isPresented: $showAlert){ - Button("Ok") {} - } - message: { - Text("Asegurate de haber llenado todos los campos requeridos") - } - - //botón de cancelar - Button("Cancelar"){ - dismiss() - } - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.gray) - .foregroundColor(.white) - .cornerRadius(10) - - } - } - .padding() - } - .navigationTitle("Agregar Paciente") - .navigationBarTitleDisplayMode(.inline) - } -} - -struct AddPatientView_Previews: PreviewProvider { - static var previews: some View { - AddPatientView(patients: PatientsViewModel()) - } -} diff --git a/nuevoamanecer/View/AdminView.swift b/nuevoamanecer/View/AdminView.swift deleted file mode 100644 index 9401633..0000000 --- a/nuevoamanecer/View/AdminView.swift +++ /dev/null @@ -1,494 +0,0 @@ -// -// AdminView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 19/05/23. -// - -import SwiftUI -import Kingfisher - - -struct AdminView: View { - - //Modelo pacientes y notas - @StateObject var patients = PatientsViewModel() - @StateObject var notes = NotesViewModel() - - //Agregar paciente - @State private var showAddPatient = false - - //Filtrado de Pacientes - @State var search: String = "" - @State private var filteredPatients: [Patient] = [] - @State private var resetFilters = false - - // Variable para rastrear si se ha seleccionado un filtro - @State private var communicationStyleFilterSelected = false - @State private var cognitiveLevelFilterSelected = false - - //opciones de comunicación y nivel cognitivo para filtros - var communicationStyles = ["Verbal", "No-verbal", "Mixto"] - var cognitiveLevels = ["Alto", "Medio", "Bajo"] - - //mostrar opciones de filtrado - @State private var showCognitiveLevelFilterOptions = false - @State private var showCommunicationStyleFilterOptions = false - - //Selección de filtrados - @State private var selectedCommunicationStyle = "" - @State private var selectedCognitiveLevel = "" - - //Selección de filtrados - @State private var showSelectedCommunicationStyle = false - @State private var showSelectedCognitiveLevel = false - - - //Reseteo de filtrado - // Filtrado - private func resetSearchFilters() { - filteredPatients = [] - selectedCommunicationStyle = "" - selectedCognitiveLevel = "" - search = "" - resetFilters = false - communicationStyleFilterSelected = false - cognitiveLevelFilterSelected = false - } - - //Busqueda por nombre o apellido - private func performSearchByName(keyword: String){ - - var searchingWithFilters = patients.patientsList - - if(communicationStyleFilterSelected){ - searchingWithFilters = searchingWithFilters.filter{ patient in - patient.communicationStyle == selectedCommunicationStyle - } - } - - if(cognitiveLevelFilterSelected){ - searchingWithFilters = searchingWithFilters.filter{ patient in - patient.cognitiveLevel == selectedCognitiveLevel - } - } - - filteredPatients = searchingWithFilters.filter{ patient in - let firstNameComponents = patient.firstName.lowercased().split(separator: " ") - let lastNameComponents = patient.lastName.lowercased().split(separator: " ") - - var firstNameMatch = false - var lastNameMatch = false - - // Busca en cada componente de firstName - for component in firstNameComponents { - if component.hasPrefix(keyword.lowercased()) { - firstNameMatch = true - break - } - } - - // Busca en cada componente de lastName - for component in lastNameComponents { - if component.hasPrefix(keyword.lowercased()) { - lastNameMatch = true - break - } - } - - return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(keyword.lowercased()) - } - } - - //Busqueda por estilo de comunicación - private func performSearchByCommunicationStyle(){ - - var searchingWithFilters = patients.patientsList - - //filtramos nivel cognitivo - if(cognitiveLevelFilterSelected){ - searchingWithFilters = searchingWithFilters.filter{ patient in - patient.cognitiveLevel == selectedCognitiveLevel - } - } - - //filtramos por busqueda en search bar - if(search != ""){ - searchingWithFilters = searchingWithFilters.filter{ patient in - let firstNameComponents = patient.firstName.lowercased().split(separator: " ") - let lastNameComponents = patient.lastName.lowercased().split(separator: " ") - - var firstNameMatch = false - var lastNameMatch = false - - // Busca en cada componente de firstName - for component in firstNameComponents { - if component.hasPrefix(search.lowercased()) { - firstNameMatch = true - break - } - } - - // Busca en cada componente de lastName - for component in lastNameComponents { - if component.hasPrefix(search.lowercased()) { - lastNameMatch = true - break - } - } - - return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(search.lowercased()) - } - } - - //si no es valida la operación, no filramos - if(selectedCommunicationStyle == "" || selectedCommunicationStyle == "Comunicación"){ - filteredPatients = searchingWithFilters - } - //si es valida la operación, filramos - else{ - filteredPatients = searchingWithFilters.filter{ patient in - patient.communicationStyle == selectedCommunicationStyle - } - } - } - - //Busqueda por nivel cognitivo - private func performSearchByCognitiveLevel(){ - - var searchingWithFilters = patients.patientsList - - //filtramos estilo de comunicación - if(communicationStyleFilterSelected){ - searchingWithFilters = searchingWithFilters.filter{ patient in - patient.communicationStyle == selectedCommunicationStyle - } - } - - //filtramos por palabras en el searchbar - if(search != ""){ - searchingWithFilters = searchingWithFilters.filter{ patient in - let firstNameComponents = patient.firstName.lowercased().split(separator: " ") - let lastNameComponents = patient.lastName.lowercased().split(separator: " ") - - var firstNameMatch = false - var lastNameMatch = false - - // Busca en cada componente de firstName - for component in firstNameComponents { - if component.hasPrefix(search.lowercased()) { - firstNameMatch = true - break - } - } - - // Busca en cada componente de lastName - for component in lastNameComponents { - if component.hasPrefix(search.lowercased()) { - lastNameMatch = true - break - } - } - - return firstNameMatch || lastNameMatch || patient.group.lowercased().hasPrefix(search.lowercased()) - } - } - - //checamos si es valida la operación - if(selectedCognitiveLevel == "" || selectedCognitiveLevel == "Nivel Cognitivo"){ - filteredPatients = searchingWithFilters - }else{ - filteredPatients = searchingWithFilters.filter{ patient in - patient.cognitiveLevel == selectedCognitiveLevel - } - } - - } - - //Lista de pacientes mostrada al usuario - /* - private var patientsListDisplayed: [Patient] { - filteredPatients.isEmpty ? patients.patientsList : filteredPatients - } - */ - - private var patientsListDisplayed: [Patient]? { - if communicationStyleFilterSelected || cognitiveLevelFilterSelected || search != "" { - return filteredPatients.isEmpty ? nil : filteredPatients - } - return patients.patientsList - } - - var body: some View { - NavigationStack{ - VStack{ - - //Boton para agregar niños - HStack{ - Spacer() - //Boton para añadir paciente - Button(action: { - showAddPatient.toggle() - }) { - HStack { - Image(systemName: "plus.circle.fill") - .resizable() - .frame(width: 20, height: 20) - Text("Agregar Niño") - .font(.headline) - } - .padding([.horizontal, .vertical], 10) - - } - - .background(Color.blue) - .foregroundColor(Color.white) - .cornerRadius(10) - } - .padding(.horizontal, 50) - //.padding(.vertical) - - // Filtrado - HStack{ - - Text("Filtrado") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(Color.gray) - - - //Nivel cognitivo - Picker("Nivel Cognitivo", selection: $selectedCognitiveLevel) { - if(!cognitiveLevelFilterSelected){ - Text("Nivel Cognitivo") - .foregroundColor(Color.black) - } - ForEach(cognitiveLevels, id: \.self) { - Text($0) - } - - } - .onChange(of: selectedCognitiveLevel, perform: { value in - performSearchByCognitiveLevel() - if selectedCognitiveLevel != "" && selectedCognitiveLevel != "Nivel Cognitivo" { - cognitiveLevelFilterSelected = true - }else { - //reseteamos valores cognitive level - cognitiveLevelFilterSelected = false - } - }) - .frame(width: 157, height: 40) - .pickerStyle(.menu) - .padding(.leading) - .cornerRadius(10) - - //Comunicación - Picker("Comunicación", selection: $selectedCommunicationStyle) { - if(!communicationStyleFilterSelected){ - Text("Comunicación") - - } - ForEach(communicationStyles, id: \.self) { - Text($0) - } - } - .onChange(of: selectedCommunicationStyle, perform: { value in - performSearchByCommunicationStyle() - if selectedCommunicationStyle != "" && selectedCommunicationStyle != "Comunicación" { - communicationStyleFilterSelected = true - }else { - //reseteamos valores cognitive level - communicationStyleFilterSelected = false - } - }) - .frame(width: 150, height: 40) - .pickerStyle(.menu) - .padding(.trailing) - .cornerRadius(10) - - - if(communicationStyleFilterSelected || cognitiveLevelFilterSelected){ - //reset filters - Button(action: { - resetFilters = true - }) { - Text("Resetear") - } - .font(.system(size: 16, weight: .bold)) - .padding(.horizontal) - .padding(.vertical, 10) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - .onChange(of: resetFilters, perform: { value in - if value { - resetSearchFilters() - } - }) - } - Spacer() - } - .padding(.horizontal, 50) - .padding(.top, 10) - - // Barra de busqueda - HStack { - HStack{ - Image(systemName: "magnifyingglass") - .resizable() - .frame(width: 20, height: 20) - .foregroundColor(Color.gray) - .padding() - TextField("Buscar niño o grupo", text: $search) - .padding() - .onChange(of: search, perform: performSearchByName) - - } - .cornerRadius(10) // Asegúrate de que este está aquí - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 10)) // Añade esta línea - - Spacer() - - } - .padding(.horizontal, 50) - .padding([.bottom, .top], 20) - - - - //mostramos que no hay pacientes con los filtros seleccionados - - if(patientsListDisplayed == nil){ - - if(patients.patientsList.count == 0){ - List{ - HStack{ - Spacer() - VStack { - Text("Aún no hay niños") - .font(.title2) - .foregroundColor(Color.gray) - .padding() - Text("Los niños que agregues se mostrarán en esta pantalla :)") - .font(.headline) - .foregroundColor(Color.gray) - } - //.padding(.top, 150) - Spacer() - } - .padding() - .background(Color.white) - .cornerRadius(10) - .padding([.leading, .trailing, .bottom, .top], 10) - } - .listStyle(.automatic) - } - else{ - List{ - HStack{ - Spacer() // Espacio superior - Text("No se han encontrado niños con ese filtrado.") - .font(.title2) - .foregroundColor(Color.gray) - Spacer() // Espacio inferior - } - .padding() - .background(Color.white) - .cornerRadius(10) - .padding([.leading, .trailing, .bottom, .top], 10) - } - //.background(Color.gray.opacity(0.1)) - .listStyle(.automatic) - - } - Spacer() - } - else{ - //mostramos lista de pacientes - List(patientsListDisplayed ?? patients.patientsList, id:\.id){ patient in - PatientCard(patient: patient) - .padding() - .background(Color.white) - .cornerRadius(10) - //.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 5) - .padding([.leading, .trailing, .bottom], 10) - .background(NavigationLink("", destination: PatientView(patients: patients, notes: notes, patient:patient)).opacity(0)) - } - .listStyle(.automatic) - .sheet(isPresented: $showAddPatient) { - AddPatientView(patients:patients) - } - } - } - } - } - } - - struct PatientCard: View { - - let patient: Patient - - var body: some View{ - VStack(alignment: .leading) { - HStack { - KFImage(URL(string: patient.image)) - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .clipShape(Circle()) - //.overlay(Circle().stroke(Color.gray, lineWidth: 2)) - //.cornerRadius(16.0) - .padding(.trailing) - - VStack(alignment: .leading) { - Text(patient.firstName + " " + patient.lastName) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(Color.black) - - VStack(alignment: .leading){ - Text("Grupo: " + patient.group) - .font(.headline) - .foregroundColor(Color.gray) - .padding(.trailing) - .padding(.vertical,2) - Text("Nivel Cognitivo: " + patient.cognitiveLevel) - .font(.headline) - .foregroundColor(Color.gray) - .padding(.trailing) - .padding(.vertical,2) - Text("Comunicación: " + patient.communicationStyle) - .font(.headline) - .foregroundColor(Color.gray) - .padding(.trailing) - .padding(.vertical,2) - } - - } - .padding(.leading) - - Spacer() - - Button(action: { - print("Comunicador") - }) { - Text("Comunicador") - .font(.system(size: 16, weight: .bold)) - .padding(.horizontal) - .padding(.vertical, 10) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - } - .padding(.horizontal) - } - .padding(.vertical, 5) - } - } - - - struct AdminView_Previews: PreviewProvider { - static var previews: some View { - AdminView() - } - } - diff --git a/nuevoamanecer/View/Authentication/AuthView.swift b/nuevoamanecer/View/Authentication/AuthView.swift deleted file mode 100644 index c5d6e32..0000000 --- a/nuevoamanecer/View/Authentication/AuthView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// AuthView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 19/05/23. -// -/* -import SwiftUI - -struct AuthView: View { - - @ObservedObject var authViewModel: AuthViewModel - - var body: some View { - NavigationView { - VStack(spacing: 50) { - Text("Bienvenido a nuestra aplicación") - .font(.largeTitle) - .fontWeight(.bold) - - Text("Una breve descripción de lo que hace tu aplicación.") - .font(.title2) - .multilineTextAlignment(.center) - - NavigationLink(destination: LoginView(authViewModel: authViewModel)) { - Text("Iniciar sesión") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.blue) - .cornerRadius(10) - } - - NavigationLink(destination: RegisterView(authViewModel: authViewModel)) { - Text("Registrarse") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.green) - .cornerRadius(10) - } - } - .padding() - .navigationTitle("Inicio") - } - .navigationViewStyle(.stack) - } -} - - -struct AuthView_Previews: PreviewProvider { - static var previews: some View { - AuthView(authViewModel: AuthViewModel()) - } -} -*/ diff --git a/nuevoamanecer/View/Authentication/LoginView.swift b/nuevoamanecer/View/Authentication/LoginView.swift deleted file mode 100644 index 91dff6a..0000000 --- a/nuevoamanecer/View/Authentication/LoginView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// LoginView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 17/05/23. -// -/* -import SwiftUI - -struct LoginView: View { - @ObservedObject var authViewModel: AuthViewModel - @State var email = "" - @State var password = "" - - var body: some View { - VStack { - Text("Iniciar sesión") - .font(.largeTitle) - .fontWeight(.bold) - - TextField("Correo electrónico", text: $email) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - .padding(.bottom, 20) - - SecureField("Contraseña", text: $password) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - .padding(.bottom, 20) - - Button(action: { - Task { - await authViewModel.signIn(email: email, password: password) - } - }) { - Text("Iniciar sesión") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.blue) - .cornerRadius(10) - } - - if let messageError = authViewModel.errorMessage { - Text(messageError) - .foregroundColor(.red) - .padding(.top, 20) - } - } - .padding() - .navigationTitle("Iniciar sesión") - } -} - - - -struct LoginView_Previews: PreviewProvider { - static var previews: some View { - LoginView(authViewModel: AuthViewModel()) - } -} -*/ diff --git a/nuevoamanecer/View/Authentication/RegisterView.swift b/nuevoamanecer/View/Authentication/RegisterView.swift deleted file mode 100644 index 778fcf3..0000000 --- a/nuevoamanecer/View/Authentication/RegisterView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// RegisterView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 17/05/23. -// -/* -import SwiftUI - -struct RegisterView: View { - @ObservedObject var authViewModel: AuthViewModel - @State var email = "" - @State var password = "" - @State var name = "" - @State var isAdmin = false - - var body: some View { - VStack { - Text("Registrarse") - .font(.largeTitle) - .fontWeight(.bold) - - TextField("Correo electrónico", text: $email) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - .padding(.bottom, 20) - - SecureField("Contraseña", text: $password) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - .padding(.bottom, 20) - - TextField("Nombre", text: $name) - .padding() - .background(Color(.systemGray6)) - .cornerRadius(10) - .padding(.bottom, 20) - - Toggle(isOn: $isAdmin) { - Text("¿Es administrador?") - } - .padding(.bottom, 20) - - Button(action: { - Task { - await authViewModel.register(email: email, password: password, name: name, isAdmin: isAdmin) - - } - }) { - Text("Registrarse") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.green) - .cornerRadius(10) - } - - if let messageError = authViewModel.errorMessage { - Text(messageError) - .foregroundColor(.red) - .padding(.top, 20) - } - } - .padding() - .navigationTitle("Registrarse") - } -} - - -struct RegisterView_Previews: PreviewProvider { - static var previews: some View { - RegisterView(authViewModel: AuthViewModel()) - } -} -*/ diff --git a/nuevoamanecer/View/EditNoteView.swift b/nuevoamanecer/View/EditNoteView.swift deleted file mode 100644 index 42af4f4..0000000 --- a/nuevoamanecer/View/EditNoteView.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// EditNoteView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 30/05/23. -// - -import SwiftUI - -struct EditNoteView: View { - @Environment(\.dismiss) var dismiss - @ObservedObject var notes: NotesViewModel - @State var note: Note - @State private var noteTitle: String = "" - @State private var noteContent: String = "" - @State private var showingAlert = false - @State private var alertTitle = "" - @State private var alertMessage = "" - - - - func initializeData(note: Note) -> Void{ - noteTitle = note.title - noteContent = note.text - } - - var body: some View { - VStack { - Text("Editar nota") - .font(.largeTitle) - .padding(.bottom) - - Form { - Section(header: Text("Título")) { - TextField("Título de la nota", text: $noteTitle) - } - - Section(header: Text("Contenido")) { - TextEditor(text: $noteContent) - .frame(minHeight: 400) - } - } - - Button(action: { - if noteTitle.isEmpty || noteContent.isEmpty { - self.alertTitle = "Faltan campos" - self.alertMessage = "Por favor, rellena todos los campos antes de guardar la nota." - self.showingAlert = true - } else { - - //let newNote = Note(id: note.id, patientId: note.patientId, order: note.order, title: noteTitle, text: noteContent) - self.note.title = noteTitle - self.note.text = noteContent - - notes.updateData(note: note){ error in - if error != "OK" { - print(error) - } - else{ - Task{ - if let notesList = await notes.getDataById(patientId: note.patientId){ - DispatchQueue.main.async{ - self.notes.notesList = notesList.sorted { $0.order < $1.order } - dismiss() - } - } - } - } - } - } - }) { - Text("Guardar nota") - .frame(minWidth: 0, maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(40) - } - .padding(.horizontal) - .alert(isPresented: $showingAlert) { - Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) - } - - } - .padding() - .onAppear{initializeData(note: note)} - } -} -struct EditNoteView_Previews: PreviewProvider { - static var previews: some View { - EditNoteView(notes: NotesViewModel(), note: Note(id: "", patientId: "", order: 0, title: "", text: "", date: Date())) - } -} diff --git a/nuevoamanecer/View/EditPatientView.swift b/nuevoamanecer/View/EditPatientView.swift deleted file mode 100644 index 4ec5bcd..0000000 --- a/nuevoamanecer/View/EditPatientView.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// EditPatientView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 26/05/23. -// - -import SwiftUI - -struct EditPatientView: View { - @ObservedObject var patients: PatientsViewModel - @State var patient: Patient - @Environment(\.dismiss) var dismiss - - - var cognitiveLevels = ["Alto", "Medio", "Bajo"] - @State private var congnitiveLevelSelector = "" - - var communicationStyles = ["Verbal", "No-verbal", "Mixto"] - @State private var communicationStyleSelector = "" - - @State var showAlert : Bool = false - @State private var firstName : String = "" - @State private var lastName : String = "" - @State private var birthDate : Date = Date.now - @State private var group : String = "" - @State private var image : String = "" - - - - func initializeData(patient: Patient) -> Void{ - firstName = patient.firstName - lastName = patient.lastName - birthDate = patient.birthDate - group = patient.group - image = patient.image - communicationStyleSelector = patient.communicationStyle - congnitiveLevelSelector = patient.cognitiveLevel - } - - - var body: some View { - VStack { - HStack{ - - - Text("Editar Información") - .font(.largeTitle) - .padding() - Spacer() - - - //Delete patient - DeletePatientView(patients:patients, patient:patient) - } - .padding() - - - Form { - Section(header: Text("Información")) { - TextField("Primer Nombre", text: $firstName) - TextField("Apellidos", text: $lastName) - TextField("Grupo", text: $group) - DatePicker("Fecha de nacimiento", selection: $birthDate, displayedComponents: .date) - } - - Section(header: Text("Nivel Cognitivo")) { - Picker("Nivel Cognitivo", selection: $congnitiveLevelSelector) { - ForEach(cognitiveLevels, id: \.self) { - Text($0) - } - } - } - - Section(header: Text("Estilo de Comunicación")) { - Picker("Tipo de comunicación", selection: $communicationStyleSelector) { - ForEach(communicationStyles, id: \.self) { - Text($0) - } - } - } - - Section { - - //botón de guardar usuario editadp - Button("Guardar"){ - - //Checar que datos son validos - if(firstName != "" && lastName != "" && group != "" && communicationStyleSelector != "" && congnitiveLevelSelector != ""){ - let patient = Patient(id: patient.id ,firstName: firstName, lastName: lastName, birthDate: birthDate, group: group, communicationStyle: communicationStyleSelector, cognitiveLevel: congnitiveLevelSelector, image: "http://github.com/davidmartinezhi.png", notes: [String]()) - - //call method for update - patients.updateData(patient: patient){ error in - if error != "OK" { - print(error) - }else{ - - Task { - if let patientsList = await patients.getData(){ - DispatchQueue.main.async { - self.patients.patientsList = patientsList - dismiss() - } - } - } - } - } - } - else{ - showAlert = true - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - .alert("Todos los campos deben ser llenados", isPresented: $showAlert){ - Button("Ok") {} - } - message: { - Text("Asegurate de haber llenado todos los campos requeridos") - } - - //botón de cancelar - Button("Cancelar"){ - dismiss() - } - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.gray) - .foregroundColor(.white) - .cornerRadius(10) - - } - } - .padding() - .onAppear{ - initializeData(patient: patient) - } - - } - - - - //.navigationTitle("Agregar Paciente") - //.navigationBarTitleDisplayMode(.inline) - } - -} - - - -struct EditPatientView_Previews: PreviewProvider { - static var previews: some View { - EditPatientView(patients: PatientsViewModel(), patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String]())) - } -} diff --git a/nuevoamanecer/View/PatientView.swift b/nuevoamanecer/View/PatientView.swift deleted file mode 100644 index 7182a70..0000000 --- a/nuevoamanecer/View/PatientView.swift +++ /dev/null @@ -1,360 +0,0 @@ -// -// PatientView.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 19/05/23. -// - -import SwiftUI -import Kingfisher - - - -struct PatientView: View { - - //ViewModels - @ObservedObject var patients : PatientsViewModel - @ObservedObject var notes : NotesViewModel - - - //patient - let patient: Patient - var age: Int? - - //showViews - @State var showAddNoteView = false - @State var showEditPatientView = false - @State private var showDeleteNoteAlert = false - @State var showEditNoteView = false - - //Note Selection - @State private var selectedNoteIndex: Int? - @State var selectedNoteToEdit: Note? - //= Note(id: "", patientId: "", order: 0, title: "", text: "") - - - private func formatDate(date: Date) -> String{ - let formatter = DateFormatter() - formatter.dateStyle = .long // change this according to your needs - formatter.timeStyle = .none // change this according to your needs - let dateString = formatter.string(from: date) - return dateString - } - - - //Retrieve Notes of patient - private func getPatientNotes(patientId: String){ - Task{ - if let notesList = await notes.getDataById(patientId: patient.id){ - DispatchQueue.main.async{ - self.notes.notesList = notesList.sorted { $0.order < $1.order } - } - } - } - } - - - //Move notes and save order in database - func moveNote(from source: IndexSet, to destination: Int) { - self.notes.notesList.move(fromOffsets: source, toOffset: destination) - - // Actualizar el orden de las notas en la base de datos - for (index, note) in self.notes.notesList.enumerated() { - // Creamos una copia de la nota para no modificar la original - var updatedNote = note - updatedNote.order = index + 1 - // Actualizamos la nota en la base de datos - self.notes.updateData(note: updatedNote) { response in - if response != "OK" { - // Aquí puedes manejar el error si lo deseas - print("Error al actualizar la nota \(updatedNote.id): \(response)") - } - } - } - } - - var body: some View { - - HStack{ - // 1/4 of the screen for the notes list - VStack { - HStack{ - Spacer() - Text("Esquema") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(Color.gray) - - - Spacer() - }.padding(.top) - - HStack{ - Button(action: { - showAddNoteView.toggle() - }) { - HStack { - Image(systemName: "plus.circle.fill") - Text("Agregar Nota") - } - } - .padding() - } - - //checamos si hay notas - if(notes.notesList.count == 0){ - - List{ - HStack{ - - Spacer() // Espacio superior - Text("Aquí podrás ver el orden de tus notas") - .foregroundColor(Color.gray) - .fixedSize(horizontal: false, vertical: true) - Spacer() // Espacio inferior - - } - } - .listStyle(.sidebar) - - }else{ - List(notes.notesList, id: \.id) { note in - Text(note.title) - .font(.system(size: 18, weight: .light)) - .foregroundColor(Color.black) - .frame(minHeight: 50) - .fixedSize(horizontal: false, vertical: true) - } - .listStyle(.sidebar) - .padding(.top) - } - } - .frame(width: UIScreen.main.bounds.width / 4) - .background(Color.white.opacity(0.1)) - - - // 3/4 of the screen for patient information and notes - VStack { - HStack { - VStack{ - KFImage(URL(string: patient.image)) - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .clipShape(Circle()) - //.shadow(radius: 10) - .padding(.trailing) - } - VStack(alignment: .leading) { - - - - Text(patient.firstName + " " + patient.lastName) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(Color.black) - - - Text("Grupo: " + patient.group) - .font(.system(size: 18, weight: .regular)) - .foregroundColor(Color.black) - .padding(.vertical, 2) - - - // Add other patient details here - Text("Nivel Cognitivo: " + patient.cognitiveLevel) - .font(.system(size: 18, weight: .regular)) - .foregroundColor(Color.black) - .padding(.vertical, 2) - - // Add other patient details here - Text("Comunicación: " + patient.communicationStyle) - .font(.system(size: 18, weight: .regular)) - .foregroundColor(Color.black) - .padding(.vertical, 2) - } - Spacer() - - VStack{ - Button(action: { - // Handle settings action here - showEditPatientView.toggle() - - }) { - HStack{ - Image(systemName: "gear") - .resizable() - .frame(width: 20, height: 20) - Text("Editar") - } - - } - - - Button(action: { - print("Comunicador") - }) { - Text("Comunicador") - .font(.system(size: 16, weight: .bold)) - .padding(.horizontal) - .padding(.vertical, 10) - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) - } - .padding() - } - } - .padding(.trailing) - - //Divider() - - //Checamos que existan pacientes - if(notes.notesList.count == 0){ - List{ - HStack{ - Spacer() - VStack { - Spacer() - Text("Agrega notas sobre tus niños, ordenalas y editalas") - .font(.title2) - .foregroundColor(Color.black) - .multilineTextAlignment(.center) - .padding() - .fixedSize(horizontal: false, vertical: true) - Text("Deja presionada una nota para mover su order y deslizala hacía la izquierda para editarla o eliminarla") - .font(.headline) - .foregroundColor(Color.gray) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding() - Spacer() - } - - Spacer() - } - .padding(.top, 100) - .background(Color.white) - .cornerRadius(10) - .padding([.leading, .trailing, .bottom, .top], 10) - } - .listStyle(.inset) - }else{ - //Lista de pacientes - List { - ForEach(Array(notes.notesList.enumerated()), id: \.element.id) { index, note in - - //Tarjeta paciente - VStack{ - HStack{ - Spacer() - Text(formatDate(date: note.date)) - .font(.system(size: 14, weight: .regular)) - .foregroundColor(Color.gray) - .padding(.trailing) - } - - HStack{ - VStack(alignment: .leading){ - Text(note.title) - .font(.system(size: 22, weight: .bold)) - .foregroundColor(Color.black) - .padding(.bottom, 2) - .fixedSize(horizontal: false, vertical: true) - Text(note.text) - .font(.system(size: 18, weight: .regular)) - .foregroundColor(Color.black) - .padding([.bottom, .top, .trailing]) - .fixedSize(horizontal: false, vertical: true) - - } - Spacer() - } - .padding([.bottom], 10) - .padding([.leading, .trailing], 15) - .background(Color.white.opacity(0.1)) - .cornerRadius(10) - //.shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2) - } - .frame(minHeight: 150) - .padding([.top, .bottom], 5) - .swipeActions(edge: .trailing) { - Button { - //selectedNote = note - selectedNoteIndex = index - showDeleteNoteAlert = true - - } label: { - Label("Eliminar", systemImage: "trash") - } - .tint(.red) - .padding() - - Button { - // Aquí va la lógica para actualizar la nota - selectedNoteToEdit = note - showEditNoteView = true - - } label: { - Label("Editar", systemImage: "pencil") - } - .tint(.blue) - .padding() - } - } - .onMove(perform: moveNote) - .padding(.top) - .alert(isPresented: $showDeleteNoteAlert) { - Alert(title: Text("Eliminar Nota"), - message: Text("¿Estás seguro de que quieres eliminar esta nota? Esta acción no se puede deshacer."), - primaryButton: .destructive(Text("Eliminar")) { - // Confirmar eliminación - if let index = self.selectedNoteIndex { - let noteId = notes.notesList[index].id - notes.deleteData(noteId: noteId) { response in - if response == "OK" { - notes.notesList.remove(atOffsets: IndexSet(integer: index)) - } else { - // Aquí puedes manejar el error si lo deseas - print("Error al eliminar la nota: \(response)") - } - } - } - self.selectedNoteIndex = nil - //self.selectedNote = nil - }, - secondaryButton: .cancel { - // Cancelar eliminación - self.selectedNoteIndex = nil - //self.selectedNote = nil - } - ) - } - } - .listStyle(.inset) - } - - - } - } - .sheet(isPresented: $showAddNoteView) { - AddNoteView(notes: notes, patient: patient) - } - .sheet(item: $selectedNoteToEdit){ note in - EditNoteView(notes: notes, note: note) - } - .sheet(isPresented: $showEditPatientView){ - EditPatientView(patients: patients, patient: patient) - } - .onAppear{ - self.getPatientNotes(patientId: patient.id) - - } - - Spacer() - } -} - -struct PatientView_Previews: PreviewProvider { - static var previews: some View { - PatientView(patients: PatientsViewModel(), notes: NotesViewModel(), patient: Patient(id:"",firstName: "",lastName: "",birthDate: Date.now, group: "", communicationStyle: "", cognitiveLevel: "", image: "", notes:[String]())) - } -} diff --git a/nuevoamanecer/ViewModel/AuthViewModel.swift b/nuevoamanecer/ViewModel/AuthViewModel.swift deleted file mode 100644 index 86c4dc5..0000000 --- a/nuevoamanecer/ViewModel/AuthViewModel.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// AuthViewModel.swift -// nuevoamanecer -// -// Created by Gerardo Martínez on 17/05/23. -// -/* - import Foundation - - - class AuthViewModel: ObservableObject { - @Published var user: User? - @Published var errorMessage: String? - - private var authDataSource = AuthenticationFirebaseDataSource() - - func getCurrentUser() async { - do { - DispatchQueue.main.async { - user = try await authDataSource.getCurrentUser() - } - } catch { - print("Error getting current user: \(error)") - user = nil - } - } - - func signIn(email: String, password: String) async { - do { - user = try await authDataSource.signIn(email: email, password: password) - errorMessage = nil // Borrar cualquier mensaje de error previo al iniciar sesión con éxito - } catch { - print("Error signing in: \(error)") - user = nil - errorMessage = error.localizedDescription // Actualizar el mensaje de error - } - } - - - func register(email: String, password: String, name: String, isAdmin: Bool) async { - do { - user = try await authDataSource.register(email: email, password: password, name: name, isAdmin: isAdmin) - } catch { - print("Error registering: \(error)") - user = nil - } - } - - func signOut() { - do { - try authDataSource.signOut() - user = nil - } catch { - print("Error signing out: \(error)") - } - } - } - */