From 54f5d0b8c4d735e16c99a3e255ca6851fbbd23bd Mon Sep 17 00:00:00 2001 From: MDeLuise <66636702+MDeLuise@users.noreply.github.com> Date: Sun, 6 Oct 2024 15:16:53 +0200 Subject: [PATCH] feature: add calendar view for reminder occurrences --- .../plantit/reminder/ReminderController.java | 26 +- .../plantit/reminder/ReminderRepository.java | 5 + .../plantit/reminder/ReminderService.java | 41 ++- .../plantit/reminder/frequency/Unit.java | 20 +- .../occurrence/ReminderOccurrence.java | 30 ++ .../occurrence/ReminderOccurrenceDTO.java | 74 +++++ .../ReminderOccurrenceDTOConverter.java | 24 ++ .../occurrence/ReminderOccurrenceService.java | 64 ++++ frontend/lib/commons.dart | 54 ++- frontend/lib/dto/reminder_occurrence.dart | 57 ++++ frontend/lib/event/event_card.dart | 29 -- .../{events.dart => events_done_section.dart} | 19 +- frontend/lib/event/events_page.dart | 57 ++++ frontend/lib/event/floating_tabbar.dart | 63 ++++ frontend/lib/event/reminder_list.dart | 84 +++++ frontend/lib/event/reminder_section.dart | 308 ++++++++++++++++++ frontend/lib/more/edit_profile.dart | 11 +- frontend/lib/signup.dart | 10 +- frontend/lib/template.dart | 2 +- frontend/pubspec.lock | 24 ++ frontend/pubspec.yaml | 10 +- frontend/test/events_test.dart | 11 +- 22 files changed, 944 insertions(+), 79 deletions(-) create mode 100644 backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrence.java create mode 100644 backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTO.java create mode 100644 backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTOConverter.java create mode 100644 backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceService.java create mode 100644 frontend/lib/dto/reminder_occurrence.dart rename frontend/lib/event/{events.dart => events_done_section.dart} (93%) create mode 100644 frontend/lib/event/events_page.dart create mode 100644 frontend/lib/event/floating_tabbar.dart create mode 100644 frontend/lib/event/reminder_list.dart create mode 100644 frontend/lib/event/reminder_section.dart diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderController.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderController.java index a81ce6b7..b731a2a1 100644 --- a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderController.java +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderController.java @@ -1,11 +1,19 @@ package com.github.mdeluise.plantit.reminder; import java.util.Collection; +import java.util.Date; import java.util.Set; import java.util.stream.Collectors; +import com.github.mdeluise.plantit.diary.entry.DiaryEntryType; +import com.github.mdeluise.plantit.reminder.occurrence.ReminderOccurrence; +import com.github.mdeluise.plantit.reminder.occurrence.ReminderOccurrenceDTO; +import com.github.mdeluise.plantit.reminder.occurrence.ReminderOccurrenceDTOConverter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +22,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -22,12 +31,15 @@ public class ReminderController { private final ReminderService reminderService; private final ReminderDTOConverter reminderDtoConverter; + private final ReminderOccurrenceDTOConverter reminderOccurrenceDtoConverter; @Autowired - public ReminderController(ReminderService reminderService, ReminderDTOConverter reminderDtoConverter) { + public ReminderController(ReminderService reminderService, ReminderDTOConverter reminderDtoConverter, + ReminderOccurrenceDTOConverter reminderOccurrenceDtoConverter) { this.reminderService = reminderService; this.reminderDtoConverter = reminderDtoConverter; + this.reminderOccurrenceDtoConverter = reminderOccurrenceDtoConverter; } @@ -67,4 +79,16 @@ public ResponseEntity update(@PathVariable Long id, @RequestBody Re final Reminder result = reminderService.update(id, reminderDtoConverter.convertFromDTO(reminderDTO)); return ResponseEntity.ok(reminderDtoConverter.convertToDTO(result)); } + + + @GetMapping("/occurrences") + public ResponseEntity> getOccurrences(@RequestParam(required = false) Long plantId, + @RequestParam(required = false) DiaryEntryType type, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Date from, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Date to, + Pageable pageable) { + final Page allOccurrences = reminderService.getAllOccurrences(type, plantId, from, to, pageable); + final Page result = allOccurrences.map(reminderOccurrenceDtoConverter::convertToDTO); + return ResponseEntity.ok(result); + } } diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderRepository.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderRepository.java index 122df2f7..e947c72d 100644 --- a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderRepository.java +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderRepository.java @@ -3,6 +3,7 @@ import java.util.List; import com.github.mdeluise.plantit.authentication.User; +import com.github.mdeluise.plantit.diary.entry.DiaryEntryType; import com.github.mdeluise.plantit.plant.Plant; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -14,4 +15,8 @@ public interface ReminderRepository extends JpaRepository { List findAllByTargetOwner(User user); List findAllByTargetAndTargetOwner(Plant target, User owner); + + List findAllByTargetOwnerAndAction(User owner, DiaryEntryType type); + + List findAllByTargetOwnerAndTargetAndAction(User owner, Plant target, DiaryEntryType type); } diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderService.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderService.java index 703b0681..e24d8053 100644 --- a/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderService.java +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/ReminderService.java @@ -1,14 +1,23 @@ package com.github.mdeluise.plantit.reminder; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.List; +import com.github.mdeluise.plantit.authentication.User; import com.github.mdeluise.plantit.common.AuthenticatedUserService; +import com.github.mdeluise.plantit.diary.entry.DiaryEntryType; import com.github.mdeluise.plantit.exception.ResourceNotFoundException; import com.github.mdeluise.plantit.exception.UnauthorizedException; import com.github.mdeluise.plantit.plant.Plant; import com.github.mdeluise.plantit.plant.PlantService; +import com.github.mdeluise.plantit.reminder.occurrence.ReminderOccurrence; +import com.github.mdeluise.plantit.reminder.occurrence.ReminderOccurrenceService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @Service @@ -16,14 +25,17 @@ public class ReminderService { private final ReminderRepository reminderRepository; private final PlantService plantService; private final AuthenticatedUserService authenticatedUserService; + private final ReminderOccurrenceService reminderOccurrenceService; @Autowired public ReminderService(ReminderRepository reminderRepository, PlantService plantService, - AuthenticatedUserService authenticatedUserService) { + AuthenticatedUserService authenticatedUserService, + ReminderOccurrenceService reminderOccurrenceService) { this.reminderRepository = reminderRepository; this.plantService = plantService; this.authenticatedUserService = authenticatedUserService; + this.reminderOccurrenceService = reminderOccurrenceService; } @@ -87,4 +99,31 @@ public Reminder save(Reminder reminder) { public void deleteAll() { reminderRepository.findAll().forEach(reminder -> remove(reminder.getId())); } + + + public Collection getAllFiltered(DiaryEntryType type, Long plantId) { + final User authenticatedUser = authenticatedUserService.getAuthenticatedUser(); + if (type == null && plantId == null) { + return reminderRepository.findAllByTargetOwner(authenticatedUser); + } else if (type == null) { + final Plant plant = plantService.get(plantId); + return reminderRepository.findAllByTargetAndTargetOwner(plant, authenticatedUser); + } else { + return reminderRepository.findAllByTargetOwnerAndAction(authenticatedUser, type); + } + } + + + public Page getAllOccurrences(DiaryEntryType type, Long plantId, Date from, Date to, + Pageable pageable) { + final Collection allFiltered = getAllFiltered(type, plantId); + final List occurrences = new ArrayList<>(); + for (Reminder reminder : allFiltered) { + occurrences.addAll(reminderOccurrenceService.getOccurrences(reminder, from, to)); + } + final int start = Math.min((int) pageable.getOffset(), occurrences.size()); + final int end = Math.min(start + pageable.getPageSize(), occurrences.size()); + final List pageContent = occurrences.subList(start, end); + return new PageImpl<>(pageContent, pageable, occurrences.size()); + } } diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/frequency/Unit.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/frequency/Unit.java index 570a85c5..d3704b09 100644 --- a/backend/src/main/java/com/github/mdeluise/plantit/reminder/frequency/Unit.java +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/frequency/Unit.java @@ -1,8 +1,20 @@ package com.github.mdeluise.plantit.reminder.frequency; +import java.util.Calendar; + public enum Unit { - DAYS, - WEEKS, - MONTHS, - YEARS + DAYS(Calendar.DAY_OF_MONTH), + WEEKS(Calendar.WEEK_OF_YEAR), + MONTHS(Calendar.MONTH), + YEARS(Calendar.YEAR); + + private final int calendarField; + + Unit(int calendarField) { + this.calendarField = calendarField; + } + + public int toCalendarField() { + return this.calendarField; + } } diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrence.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrence.java new file mode 100644 index 00000000..a5c7c2d0 --- /dev/null +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrence.java @@ -0,0 +1,30 @@ +package com.github.mdeluise.plantit.reminder.occurrence; + +import java.util.Date; + +import com.github.mdeluise.plantit.reminder.Reminder; + +public class ReminderOccurrence { + private Date date; + private Reminder reminder; + + + public Date getDate() { + return date; + } + + + public void setDate(Date date) { + this.date = date; + } + + + public Reminder getReminder() { + return reminder; + } + + + public void setReminder(Reminder reminder) { + this.reminder = reminder; + } +} diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTO.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTO.java new file mode 100644 index 00000000..d7be19ab --- /dev/null +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTO.java @@ -0,0 +1,74 @@ +package com.github.mdeluise.plantit.reminder.occurrence; + +import java.util.Date; + +import com.github.mdeluise.plantit.reminder.frequency.FrequencyDTO; + +public class ReminderOccurrenceDTO { + private Date date; + private Long reminderId; + private FrequencyDTO reminderFrequency; + private String reminderAction; + private Long reminderTargetId; + private String reminderTargetInfoPersonalName; + + + public Date getDate() { + return date; + } + + + public void setDate(Date date) { + this.date = date; + } + + + public Long getReminderId() { + return reminderId; + } + + + public void setReminderId(Long reminderId) { + this.reminderId = reminderId; + } + + + public FrequencyDTO getReminderFrequency() { + return reminderFrequency; + } + + + public void setReminderFrequency(FrequencyDTO reminderFrequency) { + this.reminderFrequency = reminderFrequency; + } + + + public String getReminderAction() { + return reminderAction; + } + + + public void setReminderAction(String reminderAction) { + this.reminderAction = reminderAction; + } + + + public Long getReminderTargetId() { + return reminderTargetId; + } + + + public void setReminderTargetId(Long reminderTargetId) { + this.reminderTargetId = reminderTargetId; + } + + + public String getReminderTargetInfoPersonalName() { + return reminderTargetInfoPersonalName; + } + + + public void setReminderTargetInfoPersonalName(String reminderTargetInfoPersonalName) { + this.reminderTargetInfoPersonalName = reminderTargetInfoPersonalName; + } +} diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTOConverter.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTOConverter.java new file mode 100644 index 00000000..496f0f5d --- /dev/null +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceDTOConverter.java @@ -0,0 +1,24 @@ +package com.github.mdeluise.plantit.reminder.occurrence; + +import com.github.mdeluise.plantit.common.AbstractDTOConverter; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Controller; + +@Controller +public class ReminderOccurrenceDTOConverter extends AbstractDTOConverter { + public ReminderOccurrenceDTOConverter(ModelMapper modelMapper) { + super(modelMapper); + } + + + @Override + public ReminderOccurrence convertFromDTO(ReminderOccurrenceDTO dto) { + return modelMapper.map(dto, ReminderOccurrence.class); + } + + + @Override + public ReminderOccurrenceDTO convertToDTO(ReminderOccurrence data) { + return modelMapper.map(data, ReminderOccurrenceDTO.class); + } +} diff --git a/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceService.java b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceService.java new file mode 100644 index 00000000..4c6439d7 --- /dev/null +++ b/backend/src/main/java/com/github/mdeluise/plantit/reminder/occurrence/ReminderOccurrenceService.java @@ -0,0 +1,64 @@ +package com.github.mdeluise.plantit.reminder.occurrence; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import com.github.mdeluise.plantit.diary.entry.DiaryEntry; +import com.github.mdeluise.plantit.diary.entry.DiaryEntryService; +import com.github.mdeluise.plantit.reminder.Reminder; +import com.github.mdeluise.plantit.reminder.frequency.Frequency; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class ReminderOccurrenceService { + private final DiaryEntryService diaryEntryService; + + + @Autowired + public ReminderOccurrenceService(DiaryEntryService diaryEntryService) { + this.diaryEntryService = diaryEntryService; + } + + + public Collection getOccurrences(Reminder reminder, Date start, Date end) { + if (!reminder.isEnabled() || reminder.getStart().after(end) || + reminder.getEnd() != null && reminder.getEnd().before(start)) { + return List.of(); + } + + final List occurrences = new ArrayList<>(); + final Optional lastAction = diaryEntryService.getLast( + reminder.getTarget().getId(), reminder.getAction()); + Date occurrenceDate; + if (lastAction.isPresent()) { + final Date lastActionDate = lastAction.get().getDate(); + occurrenceDate = addToDateOneStep(lastActionDate, reminder.getFrequency()); + } else { + occurrenceDate = reminder.getStart(); + } + while (occurrenceDate.before(end)) { + if (occurrenceDate.after(start)) { + final ReminderOccurrence occurrence = new ReminderOccurrence(); + occurrence.setDate(occurrenceDate); + occurrence.setReminder(reminder); + occurrences.add(occurrence); + } + occurrenceDate = addToDateOneStep(occurrenceDate, reminder.getFrequency()); + } + return occurrences; + } + + + private Date addToDateOneStep(Date date, Frequency frequency) { + final Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + calendar.add(frequency.getUnit().toCalendarField(), Math.max(1, frequency.getQuantity())); + return calendar.getTime(); + } + +} diff --git a/frontend/lib/commons.dart b/frontend/lib/commons.dart index a6aec232..62e6b375 100644 --- a/frontend/lib/commons.dart +++ b/frontend/lib/commons.dart @@ -54,14 +54,21 @@ const List currencySymbols = [ ]; bool isValidUrl(String url) { - final RegExp urlRegExp = RegExp( - r"(https?|http)://([-A-Z0-9.]+)(/[-A-Z0-9+&@#/%=~_|!:,.;]*)?(\?[A-Z0-9+&@#/%=~_|!:​,.;]*)?", + final RegExp urlRegExp = RegExp( + r"(https?|http)://([-A-Z0-9.]+)(/[-A-Z0-9+&@#/%=~_|!:,.;]*)?(\?[A-Z0-9+&@#/%=~_|!:​,.;]*)?", + caseSensitive: false, + ); + return urlRegExp.hasMatch(url); +} + +bool isValidEmail(String email) { + final RegExp emailRegex = RegExp( + r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$', caseSensitive: false, ); - return urlRegExp.hasMatch(url); + return emailRegex.hasMatch(email); } - String formatDate(DateTime toFormat) { final DateFormat dateFormat = DateFormat('dd/MM/yy'); return dateFormat.format(toFormat); @@ -203,7 +210,8 @@ Future fetchAndSetPlants(BuildContext context, Environment env) async { try { final totalPlantsResponse = await env.http.get("plant/_count"); if (totalPlantsResponse.statusCode != 200) { - final totalPlantsResponseBody = json.decode(utf8.decode(totalPlantsResponse.bodyBytes)); + final totalPlantsResponseBody = + json.decode(utf8.decode(totalPlantsResponse.bodyBytes)); throw AppException(totalPlantsResponseBody["message"]); } if (totalPlantsResponse.body == "0") { @@ -309,12 +317,18 @@ EventCard dtoToCard(dynamic dto, Environment env) { } const int screenSizeTreshold = 600; +const int screenSizeTreshold2 = 380; bool isSmallScreen(BuildContext context) { final double width = MediaQuery.of(context).size.width; return width < screenSizeTreshold; } +bool isReallySmallScreen(BuildContext context) { + final double width = MediaQuery.of(context).size.width; + return width < screenSizeTreshold2; +} + class SignupRequest { final String username; final String password; @@ -431,3 +445,33 @@ Future _login(Environment env, BuildContext context, String username, return false; } } + +const Map typeColors = { + 'SEEDING': Color.fromRGBO(23, 122, 105, 1), + 'WATERING': Color.fromARGB(255, 55, 91, 159), + 'FERTILIZING': Color.fromARGB(255, 199, 26, 24), + 'BIOSTIMULATING': Color.fromARGB(255, 203, 106, 32), + 'MISTING': Color.fromRGBO(0, 62, 185, 0.4), + 'TRANSPLANTING': Color.fromARGB(255, 175, 118, 89), + 'WATER_CHANGING': Color.fromRGBO(40, 108, 169, 1), + 'OBSERVATION': Color.fromRGBO(105, 105, 105, 1), + 'TREATMENT': Color.fromRGBO(185, 23, 50, 1), + 'PROPAGATING': Color.fromRGBO(17, 96, 50, 1), + 'PRUNING': Color.fromARGB(102, 62, 6, 183), + 'REPOTTING': Color.fromRGBO(144, 85, 67, 1), +}; + +const Map typeIcons = { + 'SEEDING': Icons.grass_outlined, + 'WATERING': Icons.water_drop_outlined, + 'FERTILIZING': Icons.lunch_dining_outlined, + 'BIOSTIMULATING': Icons.battery_charging_full_outlined, + 'MISTING': Icons.shower_outlined, + 'TRANSPLANTING': Icons.add_home_outlined, + 'WATER_CHANGING': Icons.waves_outlined, + 'OBSERVATION': Icons.visibility_outlined, + 'TREATMENT': Icons.science_outlined, + 'PROPAGATING': Icons.child_friendly_outlined, + 'PRUNING': Icons.cut_outlined, + 'REPOTTING': Icons.cached_outlined, +}; diff --git a/frontend/lib/dto/reminder_occurrence.dart b/frontend/lib/dto/reminder_occurrence.dart new file mode 100644 index 00000000..cd21cdac --- /dev/null +++ b/frontend/lib/dto/reminder_occurrence.dart @@ -0,0 +1,57 @@ +import 'package:plant_it/dto/reminder_dto.dart'; + +class ReminderOccurrenceDTO { + int? id; + int? reminderId; + DateTime? date; + FrequencyDTO? reminderFrequency; + DateTime? lastNotified; + String? reminderAction; + int? reminderTargetId; + String? reminderTargetInfoPersonalName; + + ReminderOccurrenceDTO({ + this.id, + this.reminderId, + this.date, + this.reminderFrequency, + this.lastNotified, + this.reminderAction, + this.reminderTargetId, + this.reminderTargetInfoPersonalName, + }); + + factory ReminderOccurrenceDTO.fromJson(Map json) { + return ReminderOccurrenceDTO( + id: json['id'], + reminderId: json['targetId'], + date: DateTime.parse(json['start']), + reminderFrequency: FrequencyDTO.fromJson(json['frequency']), + lastNotified: json["lastNotified"] != null + ? DateTime.parse(json['lastNotified']) + : null, + reminderAction: json['action'], + reminderTargetId: json['reminderTargetId'], + reminderTargetInfoPersonalName: json['reminderTargetInfoPersonalName'], + ); + } + + Map toMap() { + final Map map = {}; + if (id != null) map['id'] = id; + if (reminderAction != null) map['action'] = reminderAction; + if (reminderId != null) map['targetId'] = reminderId; + if (date != null) map['start'] = date!.toIso8601String(); + if (reminderFrequency != null) { + map['frequency'] = reminderFrequency!.toMap(); + } + if (lastNotified != null) { + map['lastNotified'] = lastNotified!.toIso8601String(); + } + if (reminderTargetId != null) map['reminderTargetId'] = reminderTargetId; + if (reminderTargetInfoPersonalName != null) { + map['reminderTargetInfoPersonalName'] = reminderTargetInfoPersonalName; + } + return map; + } +} diff --git a/frontend/lib/event/event_card.dart b/frontend/lib/event/event_card.dart index a409649a..9c7de23e 100644 --- a/frontend/lib/event/event_card.dart +++ b/frontend/lib/event/event_card.dart @@ -46,21 +46,6 @@ class EventCard extends StatelessWidget { Widget build(BuildContext context) { Color backgroundColor = Colors.grey[200]!; // Default color - const Map typeColors = { - 'SEEDING': Color.fromRGBO(23, 122, 105, 1), - 'WATERING': Color.fromARGB(255, 55, 91, 159), - 'FERTILIZING': Color.fromARGB(255, 199, 26, 24), - 'BIOSTIMULATING': Color.fromARGB(255, 203, 106, 32), - 'MISTING': Color.fromRGBO(0, 62, 185, 0.4), - 'TRANSPLANTING': Color.fromARGB(255, 175, 118, 89), - 'WATER_CHANGING': Color.fromRGBO(40, 108, 169, 1), - 'OBSERVATION': Color.fromRGBO(105, 105, 105, 1), - 'TREATMENT': Color.fromRGBO(185, 23, 50, 1), - 'PROPAGATING': Color.fromRGBO(17, 96, 50, 1), - 'PRUNING': Color.fromARGB(102, 62, 6, 183), - 'REPOTTING': Color.fromRGBO(144, 85, 67, 1), - }; - if (typeColors.containsKey(action)) { backgroundColor = typeColors[action]!; } @@ -71,20 +56,6 @@ class EventCard extends StatelessWidget { final formattedTimePassed = _formatTimePassed(context, timePassed); IconData actionIcon = Icons.info; // Default icon - final Map typeIcons = { - 'SEEDING': Icons.grass_outlined, - 'WATERING': Icons.water_drop_outlined, - 'FERTILIZING': Icons.lunch_dining_outlined, - 'BIOSTIMULATING': Icons.battery_charging_full_outlined, - 'MISTING': Icons.shower_outlined, - 'TRANSPLANTING': Icons.add_home_outlined, - 'WATER_CHANGING': Icons.waves_outlined, - 'OBSERVATION': Icons.visibility_outlined, - 'TREATMENT': Icons.science_outlined, - 'PROPAGATING': Icons.child_friendly_outlined, - 'PRUNING': Icons.cut_outlined, - 'REPOTTING': Icons.cached_outlined, - }; if (typeIcons.containsKey(action)) { actionIcon = typeIcons[action]!; diff --git a/frontend/lib/event/events.dart b/frontend/lib/event/events_done_section.dart similarity index 93% rename from frontend/lib/event/events.dart rename to frontend/lib/event/events_done_section.dart index 0520c3fd..80f0fcbb 100644 --- a/frontend/lib/event/events.dart +++ b/frontend/lib/event/events_done_section.dart @@ -16,11 +16,12 @@ class FilterWidget extends StatefulWidget { final Function(List) onSelectedEventsChanged; final Function(List) onSelectedPlantsChanged; - const FilterWidget( - {super.key, - required this.env, - required this.onSelectedEventsChanged, - required this.onSelectedPlantsChanged}); + const FilterWidget({ + super.key, + required this.env, + required this.onSelectedEventsChanged, + required this.onSelectedPlantsChanged, + }); @override State createState() => _FilterWidgetState(); @@ -86,15 +87,15 @@ class _FilterWidgetState extends State { } } -class EventsPage extends StatefulWidget { +class EventsDoneSection extends StatefulWidget { final Environment env; - const EventsPage({super.key, required this.env}); + const EventsDoneSection({super.key, required this.env}); @override - State createState() => _EventsPageState(); + State createState() => _EventsDoneSectionState(); } -class _EventsPageState extends State { +class _EventsDoneSectionState extends State { final _pageSize = 10; final PagingController _pagingController = PagingController(firstPageKey: 0); diff --git a/frontend/lib/event/events_page.dart b/frontend/lib/event/events_page.dart new file mode 100644 index 00000000..dea0590c --- /dev/null +++ b/frontend/lib/event/events_page.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:plant_it/environment.dart'; +import 'package:plant_it/event/events_done_section.dart'; +import 'package:plant_it/event/floating_tabbar.dart'; +import 'package:plant_it/event/reminder_section.dart'; + +class EventsPage extends StatefulWidget { + final Environment env; + + const EventsPage({super.key, required this.env}); + + @override + State createState() => _EventsPageState(); +} + +class _EventsPageState extends State { + int _activeIndex = 0; + + void _onTabSelected(int index) { + setState(() { + _activeIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + Widget getCurrentSection() { + if (_activeIndex == 0) { + return EventsDoneSection(env: widget.env); + } else { + return ReminderSection(env: widget.env); + } + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 32, + ), + child: Column( + children: [ + FloatingTabBar( + titles: ["Events", "Reminders"], + callbacks: [ + () => _onTabSelected(0), + () => _onTabSelected(1), + ], + ), + const SizedBox(height: 20), + Expanded( + child: getCurrentSection(), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/event/floating_tabbar.dart b/frontend/lib/event/floating_tabbar.dart new file mode 100644 index 00000000..14c274e2 --- /dev/null +++ b/frontend/lib/event/floating_tabbar.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +class FloatingTabBar extends StatefulWidget { + final List titles; + final List callbacks; + + const FloatingTabBar({ + super.key, + required this.titles, + required this.callbacks, + }); + + @override + State createState() => _FloatingTabBarState(); +} + +class _FloatingTabBarState extends State { + int _activeIndex = 0; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate(widget.titles.length, (index) { + final bool isActive = _activeIndex == index; + + return GestureDetector( + onTap: () { + setState(() { + _activeIndex = index; + }); + widget.callbacks[index](); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8.0), + padding: + const EdgeInsets.symmetric(horizontal: 15.0, vertical: 4.0), + decoration: BoxDecoration( + color: isActive + ? Color.fromARGB(255, 240, 227, 227) + : Color.fromARGB(255, 18, 48, 42), + borderRadius: BorderRadius.circular(15.0), + border: Border.all(color: Color.fromARGB(255, 18, 48, 42)), + boxShadow: const [ + BoxShadow( + color: Color.fromARGB(255, 12, 33, 29), + blurRadius: 10.0, + offset: Offset(0, 5), + ) + ], + ), + child: Text( + widget.titles[index], + style: TextStyle( + color: isActive ? Colors.black : Colors.white, + ), + ), + ), + ); + }), + ); + } +} diff --git a/frontend/lib/event/reminder_list.dart b/frontend/lib/event/reminder_list.dart new file mode 100644 index 00000000..313afb6e --- /dev/null +++ b/frontend/lib/event/reminder_list.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:plant_it/commons.dart'; +import 'package:plant_it/dto/reminder_dto.dart'; +import 'package:plant_it/dto/reminder_occurrence.dart'; + +class ReminderList extends StatelessWidget { + final List occurrences; + const ReminderList({super.key, required this.occurrences}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: occurrences + .map((e) => _ReminderOccurenceCard( + occurrence: e, + )) + .toList(), + ), + ); + } +} + +class _ReminderOccurenceCard extends StatelessWidget { + final ReminderOccurrenceDTO occurrence; + const _ReminderOccurenceCard({required this.occurrence}); + + String _formatFrequency(FrequencyDTO frequency) { + return "every ${frequency.quantity} ${frequency.unit.toString().split('.').last.toLowerCase()}"; + } + + String formatDate(DateTime toFormat) { + return '${toFormat.day.toString().padLeft(2, '0')}/${toFormat.month.toString().padLeft(2, '0')}/${toFormat.year}'; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + elevation: 6, + color: typeColors[occurrence.reminderAction], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + occurrence.reminderTargetInfoPersonalName!, + overflow: TextOverflow.ellipsis, + ), + Text( + _formatFrequency(occurrence.reminderFrequency!), + style: const TextStyle( + fontSize: 13, + color: Color.fromARGB(255, 180, 180, 180), + ), + ) + ], + ), + ), + Opacity( + opacity: .5, + child: Icon( + typeIcons[occurrence.reminderAction], + size: 40, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/event/reminder_section.dart b/frontend/lib/event/reminder_section.dart new file mode 100644 index 00000000..747d15c9 --- /dev/null +++ b/frontend/lib/event/reminder_section.dart @@ -0,0 +1,308 @@ +import 'dart:convert'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:avatar_stack/avatar_stack.dart'; +import 'package:avatar_stack/positions.dart'; +import 'package:calendar_view/calendar_view.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:plant_it/app_exception.dart'; +import 'package:plant_it/commons.dart'; +import 'package:plant_it/dto/reminder_dto.dart'; +import 'package:plant_it/dto/reminder_occurrence.dart'; +import 'package:plant_it/environment.dart'; +import 'package:plant_it/event/reminder_list.dart'; +import 'package:plant_it/events_notifier.dart'; +import 'package:provider/provider.dart'; + +class ReminderSection extends StatefulWidget { + final Environment env; + const ReminderSection({super.key, required this.env}); + + @override + State createState() => _ReminderSectionState(); +} + +class _ReminderSectionState extends State { + final GlobalKey _globalKey = GlobalKey(); + final EventController _eventController = EventController(); + late final DateTime _lastStartMonthDateFetched; + + Widget _getDayOfWeek(int day) { + final now = DateTime.now(); + final date = now.add(Duration(days: day - now.weekday)); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(15)), + color: Colors.grey, + ), + child: Center( + child: AutoSizeText( + DateFormat.E().format(date).toUpperCase(), + maxLines: 1, + ), + ), + ), + ); + } + + void _fetchReminderOccurrences(DateTime startMonthDate) async { + final String startDate = + DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(startMonthDate); + final String endDate = _getEndMonthDate(startMonthDate); + final response = await widget.env.http.get( + "reminder/occurrences?from=$startDate&to=$endDate&page=0&size=1000"); + if (response.statusCode != 200) { + widget.env.logger.error("Failed to load reminders"); + throw AppException('Failed to load reminders'); + } + + final responseBody = json.decode(response.body); + final List toAdd = []; + for (var element in responseBody["content"]) { + toAdd.add(CalendarEventData( + title: "", + date: DateTime.parse(element["date"]), + event: ReminderOccurrenceDTO( + reminderAction: element["reminderAction"], + reminderTargetInfoPersonalName: + element["reminderTargetInfoPersonalName"], + reminderFrequency: + FrequencyDTO.fromJson(element["reminderFrequency"]), + ), + )); + } + _eventController.removeWhere((e) => !toAdd.contains(e)); + _eventController.addAll(toAdd); + _lastStartMonthDateFetched = startMonthDate; + } + + String _getEndMonthDate(DateTime startMonthDate) { + final DateTime nextMonth = + DateTime(startMonthDate.year, startMonthDate.month + 1, 1); + final DateTime lastDayOfMonth = nextMonth.subtract(const Duration(days: 1)); + final DateTime lastDayEndTime = DateTime(lastDayOfMonth.year, + lastDayOfMonth.month, lastDayOfMonth.day, 23, 59, 59); + final String formattedDate = + DateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'").format(lastDayEndTime); + return formattedDate; + } + + DateTime _getStartMonthDate(DateTime date) { + final DateTime firstDayOfMonth = DateTime(date.year, date.month, 1); + final DateTime firstDayStartTime = DateTime(firstDayOfMonth.year, + firstDayOfMonth.month, firstDayOfMonth.day, 0, 0, 0); + return firstDayStartTime; + } + + void _showDayDialog(List> events, DateTime date) { + final List occurrences = + events.map((e) => e.event as ReminderOccurrenceDTO).toList(); + showDialog( + context: context, + builder: (BuildContext ctx) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Container( + constraints: const BoxConstraints( + maxWidth: 500, + maxHeight: 400, + ), + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + DateFormat('EEE, MMM dd').format(date).toUpperCase(), + style: TextStyle( + color: + Color.fromARGB(255, 180, 180, 180).withOpacity(.5), + ), + ), + ReminderList( + occurrences: occurrences, + ), + ], + ), + ), + ), + ); + }); + } + + @override + void initState() { + super.initState(); + _lastStartMonthDateFetched = _getStartMonthDate(DateTime.now()); + _fetchReminderOccurrences(_lastStartMonthDateFetched); + Provider.of(context, listen: false).addListener(() { + _fetchReminderOccurrences(_getStartMonthDate(_lastStartMonthDateFetched)); + }); + } + + @override + Widget build(BuildContext context) { + return CalendarControllerProvider( + controller: _eventController, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: MonthView( + key: _globalKey, + cellAspectRatio: 1, + showBorder: false, + onPageChange: (date, page) => _fetchReminderOccurrences(date), + cellBuilder: (date, event, isToday, isInMonth, hideDaysNotInMonth) => + Padding( + padding: const EdgeInsets.all(5.0), + child: isInMonth + ? Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(15)), + color: const Color.fromRGBO(24, 44, 37, 1), + border: isToday + ? Border.all( + color: const Color.fromRGBO(76, 175, 80, 1), + ) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (event.isNotEmpty) + _ReminderOccurrenceStack( + reminderOccurrenceDTOs: event + .map((e) => e.event as ReminderOccurrenceDTO) + .toList(), + ), + Text( + date.day.toString(), + style: TextStyle( + color: Colors.grey, + fontSize: isReallySmallScreen(context) + ? 10 + : isSmallScreen(context) + ? 10 + : 16, + ), + ), + ], + ), + ) + : null, + ), + headerBuilder: (date) => Row( + children: [ + Expanded( + flex: 1, + child: AutoSizeText( + "${DateFormat.MMMM().format(date)}, ${date.year}", + style: const TextStyle(fontSize: 40), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const Spacer(), + Row( + children: [ + IconButton( + onPressed: () => _globalKey.currentState!.previousPage(), + icon: const Icon(Icons.chevron_left_rounded), + ), + IconButton( + onPressed: () => _globalKey.currentState!.nextPage(), + icon: const Icon(Icons.chevron_right_rounded), + ), + ], + ), + ], + ), + weekDayBuilder: _getDayOfWeek, + onCellTap: (events, date) { + if (events.isEmpty) { + return; + } + _showDayDialog(events, date); + }, + ), + ), + ); + } +} + +class _ReminderOccurrenceCard extends StatelessWidget { + final ReminderOccurrenceDTO reminderOccurrenceDTO; + + const _ReminderOccurrenceCard({required this.reminderOccurrenceDTO}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth * .7; + final double iconSize = width * .8; + return Container( + width: width, + height: width, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: typeColors[reminderOccurrenceDTO.reminderAction], + ), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: Center( + child: Icon( + typeIcons[reminderOccurrenceDTO.reminderAction], + size: iconSize, + ), + ), + ), + ); + }, + ); + } +} + +class _ReminderOccurrenceStack extends StatelessWidget { + final List reminderOccurrenceDTOs; + + const _ReminderOccurrenceStack({required this.reminderOccurrenceDTOs}); + + @override + Widget build(BuildContext context) { + final List<_ReminderOccurrenceCard> avatars = reminderOccurrenceDTOs + .map((e) => _ReminderOccurrenceCard( + reminderOccurrenceDTO: e, + )) + .toList(); + return SizedBox( + height: isReallySmallScreen(context) + ? 20 + : isSmallScreen(context) + ? 24 + : 34, + child: WidgetStack( + positions: RestrictedPositions( + maxCoverage: .4, + minCoverage: .4, + align: StackAlign.center, + ), + buildInfoWidget: (surplus) { + return Center( + child: Text( + '+$surplus', + style: const TextStyle( + color: Colors.grey, + ), + )); + }, + stackedWidgets: avatars, + ), + ); + } +} diff --git a/frontend/lib/more/edit_profile.dart b/frontend/lib/more/edit_profile.dart index 14f5151c..82951abb 100644 --- a/frontend/lib/more/edit_profile.dart +++ b/frontend/lib/more/edit_profile.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_advanced_avatar/flutter_advanced_avatar.dart'; import 'package:material_loading_buttons/material_loading_buttons.dart'; import 'package:plant_it/app_exception.dart'; +import 'package:plant_it/commons.dart'; import 'package:plant_it/environment.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:plant_it/info_entries.dart'; @@ -36,18 +37,10 @@ class _EditProfilePageState extends State { _email = widget.env.credentials.email; } - bool _isValidEmail(String email) { - final RegExp emailRegex = RegExp( - r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$', - caseSensitive: false, - ); - return emailRegex.hasMatch(email); - } - void _updateUser() async { bool changed = false; if (widget.env.credentials.email != _email) { - if (!_isValidEmail(_email)) { + if (!isValidEmail(_email)) { widget.env.logger.error("Enter valid email"); throw AppException("Enter valid email"); } diff --git a/frontend/lib/signup.dart b/frontend/lib/signup.dart index f03a89e9..96986414 100644 --- a/frontend/lib/signup.dart +++ b/frontend/lib/signup.dart @@ -74,14 +74,6 @@ class _SignupPageState extends State { } } - bool _isValidEmail(String email) { - final RegExp emailRegex = RegExp( - r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$', - caseSensitive: false, - ); - return emailRegex.hasMatch(email); - } - @override void dispose() { _usernameController.dispose(); @@ -137,7 +129,7 @@ class _SignupPageState extends State { if (value == null || value.isEmpty) { return AppLocalizations.of(context).enterValue; } - if (!_isValidEmail(value)) { + if (!isValidEmail(value)) { return AppLocalizations.of(context) .enterValidEmail; } diff --git a/frontend/lib/template.dart b/frontend/lib/template.dart index 6b1f7bfb..2cfc27c6 100644 --- a/frontend/lib/template.dart +++ b/frontend/lib/template.dart @@ -3,7 +3,7 @@ import 'package:animated_bottom_navigation_bar/animated_bottom_navigation_bar.da import 'package:plant_it/event/add_new_event.dart'; import 'package:plant_it/commons.dart'; import 'package:plant_it/environment.dart'; -import 'package:plant_it/event/events.dart'; +import 'package:plant_it/event/events_page.dart'; import 'package:plant_it/homepage/homepage.dart'; import 'package:plant_it/more/more_page.dart'; import 'package:plant_it/search/search_page.dart'; diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index de20610c..4c625171 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -62,6 +62,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + avatar_stack: + dependency: "direct main" + description: + name: avatar_stack + sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49 + url: "https://pub.dev" + source: hosted + version: "1.2.0" boolean_selector: dependency: transitive description: @@ -158,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + calendar_view: + dependency: "direct main" + description: + name: calendar_view + sha256: "0268c53439f6348f11a35e8a87493843473ab7c28190fe9f38c9ee05a9621564" + url: "https://pub.dev" + source: hosted + version: "1.2.0" characters: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 36b02ac8..5f781323 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -54,17 +54,13 @@ dependencies: toastification: ^2.3.0 image_picker: ^1.1.2 http_parser: ^4.0.2 - # flutter_side_menu: ^0.4.0 - # colorful_safe_area: ^1.0.0 - # icon_animated: ^1.2.1 - # flutter_icon_snackbar: ^1.1.7 - # flutter_staggered_animations: ^1.1.1 - # settings_ui: ^2.0.2 - # awesome_snackbar_content: ^0.1.3 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + calendar_view: ^1.2.0 + avatar_stack: ^1.2.0 + auto_size_text: ^3.0.0 dev_dependencies: flutter_test: diff --git a/frontend/test/events_test.dart b/frontend/test/events_test.dart index b2fb024a..1a230111 100644 --- a/frontend/test/events_test.dart +++ b/frontend/test/events_test.dart @@ -6,7 +6,7 @@ import 'package:mockito/mockito.dart'; import 'package:plant_it/app_http_client.dart'; import 'package:plant_it/environment.dart'; import 'package:plant_it/event/event_card.dart'; -import 'package:plant_it/event/events.dart'; +import 'package:plant_it/event/events_done_section.dart'; import 'package:plant_it/logger/logger.dart'; import 'package:plant_it/toast/toast_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -59,7 +59,8 @@ void main() { testWidgets('Events widget has correct fields', (tester) async { // Arrange await tester.pumpWidget(LocalizationsInjector( - navigatorObserver: navigatorObserver, child: EventsPage(env: env))); + navigatorObserver: navigatorObserver, + child: EventsDoneSection(env: env))); expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget); // Act @@ -126,7 +127,8 @@ void main() { // Arrange await tester.pumpWidget(LocalizationsInjector( - navigatorObserver: navigatorObserver, child: EventsPage(env: env))); + navigatorObserver: navigatorObserver, + child: EventsDoneSection(env: env))); await tester.pumpAndSettle(); // Assert and verify @@ -189,7 +191,8 @@ void main() { // Arrange await tester.pumpWidget(LocalizationsInjector( - navigatorObserver: navigatorObserver, child: EventsPage(env: env))); + navigatorObserver: navigatorObserver, + child: EventsDoneSection(env: env))); await tester.pumpAndSettle(); // Act