From 0ece9382af13e6ea0e23a4fea31e49dd861ede58 Mon Sep 17 00:00:00 2001 From: ReinerRego Date: Fri, 26 May 2023 21:51:21 +0200 Subject: [PATCH] remelem mukszik --- filcnaplo_mobile_ui/LICENSE | 29 + filcnaplo_mobile_ui/README.md | 0 filcnaplo_mobile_ui/analysis_options.yaml | 28 + .../lib/common/action_button.dart | 36 + .../lib/common/average_display.dart | 35 + .../lib/common/bottom_card.dart | 51 + .../bottom_sheet_menu/bottom_sheet_menu.dart | 22 + .../bottom_sheet_menu_item.dart | 19 + .../rounded_bottom_sheet.dart | 70 ++ .../lib/common/custom_snack_bar.dart | 34 + filcnaplo_mobile_ui/lib/common/detail.dart | 31 + .../lib/common/dialog_button.dart | 23 + filcnaplo_mobile_ui/lib/common/dot.dart | 20 + filcnaplo_mobile_ui/lib/common/empty.dart | 45 + .../lib/common/filter_bar.dart | 117 +++ .../lib/common/hero_dialog_route.dart | 35 + .../lib/common/hero_scrollview.dart | 133 +++ .../lib/common/material_action_button.dart | 35 + .../lib/common/new_content_indicator.dart | 34 + .../lib/common/panel/panel.dart | 135 +++ .../lib/common/panel/panel_action_button.dart | 44 + .../lib/common/panel/panel_button.dart | 74 ++ .../common/profile_image/profile_button.dart | 50 + .../common/profile_image/profile_image.dart | 229 ++++ .../lib/common/progress_bar.dart | 69 ++ .../lib/common/screens.i18n.dart | 33 + .../lib/common/sliding_bottom_sheet.dart | 42 + .../lib/common/system_chrome.dart | 15 + .../lib/common/trend_display.dart | 59 ++ filcnaplo_mobile_ui/lib/common/viewable.dart | 979 ++++++++++++++++++ .../widgets/absence/absence_display.dart | 50 + .../widgets/absence/absence_subject_tile.dart | 80 ++ .../common/widgets/absence/absence_tile.dart | 118 +++ .../widgets/absence/absence_tile.i18n.dart | 36 + .../common/widgets/absence/absence_view.dart | 128 +++ .../widgets/absence/absence_view.i18n.dart | 39 + .../widgets/absence/absence_viewable.dart | 68 ++ .../absence_group_container.dart | 10 + .../absence_group/absence_group_tile.dart | 80 ++ .../absence_group_tile.i18n.dart | 21 + .../lib/common/widgets/card_handle.dart | 27 + .../cretification/certification_card.dart | 108 ++ .../certification_card.i18n.dart | 36 + .../cretification/certification_tile.dart | 87 ++ .../certification_tile.i18n.dart | 45 + .../cretification/certification_view.dart | 43 + .../lib/common/widgets/custom_switch.dart | 60 ++ .../lib/common/widgets/event/event_tile.dart | 46 + .../lib/common/widgets/event/event_view.dart | 57 + .../common/widgets/event/event_viewable.dart | 18 + .../lib/common/widgets/exam/exam_tile.dart | 58 ++ .../lib/common/widgets/exam/exam_view.dart | 61 ++ .../common/widgets/exam/exam_view.i18n.dart | 27 + .../common/widgets/exam/exam_viewable.dart | 20 + .../widgets/grade/grade_subject_tile.dart | 70 ++ .../lib/common/widgets/grade/grade_view.dart | 60 ++ .../common/widgets/grade/grade_view.i18n.dart | 30 + .../common/widgets/grade/grade_viewable.dart | 25 + .../lib/common/widgets/grade/new_grades.dart | 158 +++ .../common/widgets/grade/new_grades.i18n.dart | 42 + .../common/widgets/grade/surprise_grade.dart | 389 +++++++ .../homework/homework_attachment_tile.dart | 89 ++ .../homework_attachment_tile.i18n.dart | 21 + .../widgets/homework/homework_tile.dart | 103 ++ .../widgets/homework/homework_view.dart | 88 ++ .../widgets/homework/homework_view.i18n.dart | 21 + .../widgets/homework/homework_viewable.dart | 18 + .../widgets/lesson/changed_lesson_tile.dart | 75 ++ .../lesson/changed_lesson_tile.i18n.dart | 24 + .../lesson/changed_lesson_viewable.dart | 18 + .../common/widgets/lesson/lesson_view.dart | 80 ++ .../widgets/lesson/lesson_view.i18n.dart | 30 + .../widgets/lesson/lesson_viewable.dart | 25 + .../widgets/message/attachment_tile.dart | 83 ++ .../common/widgets/message/image_view.dart | 46 + .../common/widgets/message/message_view.dart | 53 + .../widgets/message/message_view_tile.dart | 122 +++ .../message/message_view_tile.i18n.dart | 24 + .../widgets/message/message_viewable.dart | 32 + .../lib/common/widgets/miss_tile.dart | 51 + .../lib/common/widgets/miss_tile.i18n.dart | 24 + .../widgets/missed_exam/missed_exam_tile.dart | 35 + .../missed_exam/missed_exam_tile.i18n.dart | 63 ++ .../widgets/missed_exam/missed_exam_view.dart | 61 ++ .../missed_exam/missed_exam_viewable.dart | 18 + .../lib/common/widgets/note/note_tile.dart | 46 + .../lib/common/widgets/note/note_view.dart | 72 ++ .../common/widgets/note/note_viewable.dart | 18 + .../lib/common/widgets/statistics_tile.dart | 107 ++ .../common/widgets/update/update_tile.dart | 32 + .../widgets/update/update_tile.i18n.dart | 21 + .../widgets/update/update_viewable.dart | 18 + .../common/widgets/update/updates_view.dart | 170 +++ .../widgets/update/updates_view.i18n.dart | 46 + .../pages/absences/absence_subject_view.dart | 79 ++ .../absence_subject_view_container.dart | 10 + .../lib/pages/absences/absences_page.dart | 382 +++++++ .../pages/absences/absences_page.i18n.dart | 57 + .../grades/calculator/grade_calculator.dart | 167 +++ .../calculator/grade_calculator.i18n.dart | 33 + .../calculator/grade_calculator_provider.dart | 53 + .../lib/pages/grades/fail_warning.dart | 39 + .../lib/pages/grades/grade_subject_view.dart | 283 +++++ .../lib/pages/grades/grades_count.dart | 23 + .../lib/pages/grades/grades_count_item.dart | 33 + .../lib/pages/grades/grades_page.dart | 294 ++++++ .../lib/pages/grades/grades_page.i18n.dart | 60 ++ .../lib/pages/grades/graph.dart | 295 ++++++ .../lib/pages/grades/graph.i18n.dart | 24 + .../grades/subject_grades_container.dart | 10 + .../lib/pages/home/home_page.dart | 357 +++++++ .../lib/pages/home/home_page.i18n.dart | 63 ++ .../home/live_card/heads_up_countdown.dart | 102 ++ .../lib/pages/home/live_card/live_card.dart | 197 ++++ .../pages/home/live_card/live_card.i18n.dart | 57 + .../home/live_card/live_card_widget.dart | 247 +++++ .../lib/pages/home/particle.dart | 438 ++++++++ .../lib/pages/messages/messages_page.dart | 179 ++++ .../pages/messages/messages_page.i18n.dart | 36 + .../lib/pages/timetable/day_title.dart | 62 ++ .../lib/pages/timetable/timetable_page.dart | 472 +++++++++ .../pages/timetable/timetable_page.i18n.dart | 30 + .../components/active_sponsor_card.dart | 142 +++ .../lib/premium/components/avatar_stack.dart | 26 + .../lib/premium/components/github_card.dart | 52 + .../components/github_connect_button.dart | 97 ++ .../lib/premium/components/goal_card.dart | 74 ++ .../lib/premium/components/plan_card.dart | 138 +++ .../lib/premium/components/reward_card.dart | 64 ++ .../premium/components/supporter_chip.dart | 35 + .../components/supporter_group_card.dart | 71 ++ .../premium/components/supporter_tile.dart | 23 + .../premium/components/supporters_button.dart | 70 ++ .../lib/premium/premium_button.dart | 119 +++ .../lib/premium/premium_screen.dart | 292 ++++++ .../lib/premium/styles/gradients.dart | 13 + .../lib/premium/supporters_screen.dart | 121 +++ .../lib/screens/error_report_screen.dart | 200 ++++ .../lib/screens/error_report_screen.i18n.dart | 45 + .../lib/screens/error_screen.dart | 64 ++ .../lib/screens/login/login_button.dart | 29 + .../lib/screens/login/login_input.dart | 97 ++ .../lib/screens/login/login_route.dart | 21 + .../lib/screens/login/login_screen.dart | 303 ++++++ .../lib/screens/login/login_screen.i18n.dart | 51 + .../login/school_input/school_input.dart | 117 +++ .../school_input/school_input_overlay.dart | 72 ++ .../school_input_overlay.i18n.dart | 21 + .../login/school_input/school_input_tile.dart | 64 ++ .../login/school_input/school_search.dart | 25 + .../lib/screens/navigation/nabar.dart | 27 + .../lib/screens/navigation/navbar_item.dart | 59 ++ .../screens/navigation/navigation_route.dart | 25 + .../navigation/navigation_route_handler.dart | 38 + .../screens/navigation/navigation_screen.dart | 302 ++++++ .../lib/screens/navigation/status_bar.dart | 110 ++ .../screens/navigation/status_bar.i18n.dart | 27 + .../lib/screens/news/news_screen.dart | 61 ++ .../lib/screens/news/news_tile.dart | 30 + .../lib/screens/news/news_view.dart | 116 +++ .../settings/accounts/account_tile.dart | 40 + .../settings/accounts/account_view.dart | 54 + .../settings/accounts/account_view.i18n.dart | 33 + .../settings/debug/subject_icon_gallery.dart | 81 ++ .../lib/screens/settings/privacy_view.dart | 61 ++ .../lib/screens/settings/settings_helper.dart | 548 ++++++++++ .../lib/screens/settings/settings_route.dart | 21 + .../lib/screens/settings/settings_screen.dart | 816 +++++++++++++++ .../settings/settings_screen.i18n.dart | 194 ++++ filcnaplo_mobile_ui/pubspec.yaml | 47 + 170 files changed, 15575 insertions(+) create mode 100755 filcnaplo_mobile_ui/LICENSE create mode 100755 filcnaplo_mobile_ui/README.md create mode 100755 filcnaplo_mobile_ui/analysis_options.yaml create mode 100755 filcnaplo_mobile_ui/lib/common/action_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/average_display.dart create mode 100755 filcnaplo_mobile_ui/lib/common/bottom_card.dart create mode 100755 filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu.dart create mode 100755 filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu_item.dart create mode 100755 filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/rounded_bottom_sheet.dart create mode 100755 filcnaplo_mobile_ui/lib/common/custom_snack_bar.dart create mode 100755 filcnaplo_mobile_ui/lib/common/detail.dart create mode 100755 filcnaplo_mobile_ui/lib/common/dialog_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/dot.dart create mode 100755 filcnaplo_mobile_ui/lib/common/empty.dart create mode 100755 filcnaplo_mobile_ui/lib/common/filter_bar.dart create mode 100755 filcnaplo_mobile_ui/lib/common/hero_dialog_route.dart create mode 100755 filcnaplo_mobile_ui/lib/common/hero_scrollview.dart create mode 100755 filcnaplo_mobile_ui/lib/common/material_action_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/new_content_indicator.dart create mode 100755 filcnaplo_mobile_ui/lib/common/panel/panel.dart create mode 100755 filcnaplo_mobile_ui/lib/common/panel/panel_action_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/panel/panel_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/profile_image/profile_button.dart create mode 100755 filcnaplo_mobile_ui/lib/common/profile_image/profile_image.dart create mode 100755 filcnaplo_mobile_ui/lib/common/progress_bar.dart create mode 100755 filcnaplo_mobile_ui/lib/common/screens.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/sliding_bottom_sheet.dart create mode 100755 filcnaplo_mobile_ui/lib/common/system_chrome.dart create mode 100755 filcnaplo_mobile_ui/lib/common/trend_display.dart create mode 100755 filcnaplo_mobile_ui/lib/common/viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_display.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_subject_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence/absence_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_container.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/card_handle.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/custom_switch.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/event/event_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/event/event_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/event/event_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/exam/exam_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/exam/exam_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/grade_subject_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/grade_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/grade/surprise_grade.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/homework/homework_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/attachment_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/image_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/message_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/message/message_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/miss_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/miss_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/note/note_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/note/note_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/note/note_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/statistics_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/update/update_viewable.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.dart create mode 100755 filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view_container.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/absences/absences_page.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/absences/absences_page.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator_provider.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/fail_warning.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/grade_subject_view.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/grades_count.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/grades_count_item.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/grades_page.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/grades_page.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/graph.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/graph.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/grades/subject_grades_container.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/home_page.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/home_page.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/live_card/heads_up_countdown.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/live_card/live_card_widget.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/home/particle.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/messages/messages_page.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/messages/messages_page.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/timetable/day_title.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.dart create mode 100755 filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/active_sponsor_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/avatar_stack.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/github_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/github_connect_button.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/goal_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/plan_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/reward_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/supporter_chip.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/supporter_group_card.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/supporter_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/components/supporters_button.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/premium_button.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/premium_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/styles/gradients.dart create mode 100755 filcnaplo_mobile_ui/lib/premium/supporters_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/error_report_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/error_report_screen.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/error_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/login_button.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/login_input.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/login_route.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/login_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/login_screen.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/school_input/school_input.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/login/school_input/school_search.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/nabar.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/navbar_item.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/navigation_route.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/navigation_route_handler.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/navigation_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/status_bar.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/navigation/status_bar.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/news/news_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/news/news_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/news/news_view.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/accounts/account_tile.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.i18n.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/debug/subject_icon_gallery.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/privacy_view.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/settings_helper.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/settings_route.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/settings_screen.dart create mode 100755 filcnaplo_mobile_ui/lib/screens/settings/settings_screen.i18n.dart create mode 100755 filcnaplo_mobile_ui/pubspec.yaml diff --git a/filcnaplo_mobile_ui/LICENSE b/filcnaplo_mobile_ui/LICENSE new file mode 100755 index 0000000..a23e6cd --- /dev/null +++ b/filcnaplo_mobile_ui/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, Filc +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/filcnaplo_mobile_ui/README.md b/filcnaplo_mobile_ui/README.md new file mode 100755 index 0000000..e69de29 diff --git a/filcnaplo_mobile_ui/analysis_options.yaml b/filcnaplo_mobile_ui/analysis_options.yaml new file mode 100755 index 0000000..fd16f92 --- /dev/null +++ b/filcnaplo_mobile_ui/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/filcnaplo_mobile_ui/lib/common/action_button.dart b/filcnaplo_mobile_ui/lib/common/action_button.dart new file mode 100755 index 0000000..80c6aa9 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/action_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class ActionButton extends StatelessWidget { + const ActionButton({Key? key, required this.label, this.activeColor, this.onTap}) : super(key: key); + + final Color? activeColor; + final void Function()? onTap; + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 6.0, bottom: 6.0, right: 3.0), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: 32.0, + decoration: BoxDecoration( + color: (activeColor ?? Theme.of(context).colorScheme.secondary).withOpacity(0.25), + borderRadius: BorderRadius.circular(6.0), + ), + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0), + child: Center( + child: Text(label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15.0, fontWeight: FontWeight.w600, color: activeColor ?? Theme.of(context).colorScheme.secondary))), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/average_display.dart b/filcnaplo_mobile_ui/lib/common/average_display.dart new file mode 100755 index 0000000..f4d48bd --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/average_display.dart @@ -0,0 +1,35 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_widget.dart'; + +class AverageDisplay extends StatelessWidget { + const AverageDisplay({Key? key, this.average = 0.0, this.border = false}) : super(key: key); + + final double average; + final bool border; + + @override + Widget build(BuildContext context) { + Color color = average == 0.0 ? AppColors.of(context).text.withOpacity(.8) : gradeColor(context: context, value: average); + + String averageText = average.toStringAsFixed(2); + if (I18n.of(context).locale.languageCode != "en") averageText = averageText.replaceAll(".", ","); + + return Container( + width: border ? 57.0 : 54.0, + padding: EdgeInsets.symmetric(horizontal: 8.0 - (border ? 2 : 0), vertical: 6.0 - (border ? 2 : 0)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(45.0), + border: border ? Border.fromBorderSide(BorderSide(color: color.withOpacity(.5), width: 3.0)) : null, + color: !border ? color.withOpacity(average == 0.0 ? .15 : .25) : null, + ), + child: Text( + average == 0.0 ? "-" : averageText, + textAlign: TextAlign.center, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + maxLines: 1, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/bottom_card.dart b/filcnaplo_mobile_ui/lib/common/bottom_card.dart new file mode 100755 index 0000000..c91ab55 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/bottom_card.dart @@ -0,0 +1,51 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class BottomCard extends StatelessWidget { + const BottomCard({Key? key, this.child}) : super(key: key); + + final Widget? child; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.0), + color: Theme.of(context).colorScheme.background, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42.0, + height: 4.0, + margin: const EdgeInsets.only(top: 12.0, bottom: 4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(45.0), + color: AppColors.of(context).text.withOpacity(0.10), + ), + ), + if (child != null) child!, + ], + ), + ), + ), + ); + } +} + +Future showBottomCard({ + required BuildContext context, + Widget? child, + bool rootNavigator = true, +}) async => + await showModalBottomSheet( + backgroundColor: const Color(0x00000000), + useRootNavigator: rootNavigator, + elevation: 0, + isDismissible: true, + context: context, + builder: (context) => BottomCard(child: child)); diff --git a/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu.dart b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu.dart new file mode 100755 index 0000000..0b877ac --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu.dart @@ -0,0 +1,22 @@ +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart'; +import 'package:flutter/material.dart'; + +class BottomSheetMenu extends StatelessWidget { + const BottomSheetMenu({Key? key, this.items = const []}) : super(key: key); + + final List items; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: items, + ), + ); + } +} + +void showBottomSheetMenu(BuildContext context, {List items = const []}) => + showRoundedModalBottomSheet(context, child: BottomSheetMenu(items: items)); diff --git a/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu_item.dart b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu_item.dart new file mode 100755 index 0000000..6e32059 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/bottom_sheet_menu_item.dart @@ -0,0 +1,19 @@ +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:flutter/material.dart'; + +class BottomSheetMenuItem extends StatelessWidget { + const BottomSheetMenuItem({Key? key, required this.onPressed, required this.title, this.icon}) : super(key: key); + + final void Function()? onPressed; + final Widget? title; + final Widget? icon; + + @override + Widget build(BuildContext context) { + return PanelButton( + onPressed: onPressed, + leading: icon, + title: title, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/rounded_bottom_sheet.dart b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/rounded_bottom_sheet.dart new file mode 100755 index 0000000..71294d0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/bottom_sheet_menu/rounded_bottom_sheet.dart @@ -0,0 +1,70 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class RoundedBottomSheet extends StatelessWidget { + const RoundedBottomSheet({Key? key, this.child, this.borderRadius = 12.0, this.shrink = true, this.showHandle = true}) : super(key: key); + + final Widget? child; + final double borderRadius; + final bool shrink; + final bool showHandle; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 500), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(borderRadius), + topRight: Radius.circular(borderRadius), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: shrink ? MainAxisSize.min : MainAxisSize.max, + children: [ + if (showHandle) + Container( + width: 42.0, + height: 4.0, + margin: const EdgeInsets.only(top: 12.0, bottom: 4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(45.0), + color: AppColors.of(context).text.withOpacity(0.10), + ), + ), + if (child != null) child!, + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ), + ); + } +} + +Future showRoundedModalBottomSheet( + BuildContext context, { + required Widget child, + bool rootNavigator = true, +}) async { + return await showModalBottomSheet( + context: context, + backgroundColor: const Color(0x00000000), + elevation: 0, + isDismissible: true, + useRootNavigator: rootNavigator, + builder: (context) => RoundedBottomSheet(child: child)); +} + +PersistentBottomSheetController showRoundedBottomSheet( + BuildContext context, { + required Widget child, +}) { + return showBottomSheet( + context: context, + backgroundColor: const Color(0x00000000), + elevation: 12.0, + builder: (context) => RoundedBottomSheet(child: child), + ); +} diff --git a/filcnaplo_mobile_ui/lib/common/custom_snack_bar.dart b/filcnaplo_mobile_ui/lib/common/custom_snack_bar.dart new file mode 100755 index 0000000..78cbc8e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/custom_snack_bar.dart @@ -0,0 +1,34 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +// ignore: non_constant_identifier_names +SnackBar CustomSnackBar({ + required Widget content, + required BuildContext context, + Brightness? brightness, + Color? backgroundColor, + Duration? duration, +}) { + // backgroundColor > Brightness > Theme Background + Color _backgroundColor = backgroundColor ?? (AppColors.fromBrightness(brightness ?? Theme.of(context).brightness).highlight); + Color textColor = AppColors.fromBrightness(brightness ?? Theme.of(context).brightness).text; + + return SnackBar( + duration: duration ?? const Duration(seconds: 4), + content: Container( + decoration: BoxDecoration( + color: _backgroundColor, + borderRadius: BorderRadius.circular(6.0), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(.15), blurRadius: 4.0)], + ), + padding: const EdgeInsets.all(12.0), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: textColor, fontWeight: FontWeight.w500), + child: content, + ), + ), + backgroundColor: const Color(0x00000000), + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + ); +} diff --git a/filcnaplo_mobile_ui/lib/common/detail.dart b/filcnaplo_mobile_ui/lib/common/detail.dart new file mode 100755 index 0000000..3376d6d --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/detail.dart @@ -0,0 +1,31 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class Detail extends StatelessWidget { + const Detail({Key? key, required this.title, required this.description, this.maxLines = 3}) : super(key: key); + + final String title; + final String description; + final int? maxLines; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 18.0), + child: SelectableText.rich( + TextSpan( + text: "$title: ", + style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.of(context).text), + children: [ + TextSpan( + text: description, + style: TextStyle(fontWeight: FontWeight.w500, color: AppColors.of(context).text.withOpacity(0.85)), + ), + ], + ), + minLines: 1, + maxLines: maxLines, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/dialog_button.dart b/filcnaplo_mobile_ui/lib/common/dialog_button.dart new file mode 100755 index 0000000..57f0a8c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/dialog_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class DialogButton extends StatelessWidget { + const DialogButton({Key? key, required this.label, this.onTap}) : super(key: key); + + final String label; + final Function()? onTap; + + @override + Widget build(BuildContext context) { + return RawMaterialButton( + onPressed: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + child: Text( + label.toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/dot.dart b/filcnaplo_mobile_ui/lib/common/dot.dart new file mode 100755 index 0000000..17daa3e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/dot.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class Dot extends StatelessWidget { + final Color color; + final double size; + + const Dot({Key? key, this.color = Colors.grey, this.size = 16.0}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + width: size, + height: size, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/empty.dart b/filcnaplo_mobile_ui/lib/common/empty.dart new file mode 100755 index 0000000..d4f2915 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/empty.dart @@ -0,0 +1,45 @@ +import 'dart:math'; + +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +List faces = [ + "(·.·)", + "(≥o≤)", + "(·_·)", + "(˚Δ˚)b", + "(^-^*)", + "(='X'=)", + "(>_<)", + "(;-;)", + "\\(^Д^)/", + "\\(o_o)/", +]; + +class Empty extends StatelessWidget { + const Empty({Key? key, this.subtitle}) : super(key: key); + + final String? subtitle; + + @override + Widget build(BuildContext context) { + // make the face randomness a bit more constant (to avoid strokes) + int index = Random(DateTime.now().minute).nextInt(faces.length); + + return Center( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Text.rich( + TextSpan( + text: faces[index], + style: TextStyle(fontSize: 32.0, fontWeight: FontWeight.w500, color: AppColors.of(context).text.withOpacity(.75)), + children: subtitle != null + ? [TextSpan(text: "\n" + subtitle!, style: TextStyle(fontSize: 18.0, height: 2.0, color: AppColors.of(context).text.withOpacity(.5)))] + : [], + ), + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/filter_bar.dart b/filcnaplo_mobile_ui/lib/common/filter_bar.dart new file mode 100755 index 0000000..ba7a1a1 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/filter_bar.dart @@ -0,0 +1,117 @@ +import 'dart:math'; + +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class FilterBar extends StatefulWidget implements PreferredSizeWidget { + const FilterBar({ + Key? key, + required this.items, + required this.controller, + this.onTap, + this.padding = const EdgeInsets.symmetric(horizontal: 24.0), + this.disableFading = false, + this.scrollable = true, + this.censored = false, + }) : assert(items.length == controller.length), + super(key: key); + + final List items; + final TabController controller; + final EdgeInsetsGeometry padding; + final Function(int)? onTap; + final bool disableFading; + final bool scrollable; + final bool censored; + + @override + final Size preferredSize = const Size.fromHeight(42.0); + + @override + State createState() => _FilterBarState(); +} + +class _FilterBarState extends State { + List censoredItemsWidth = []; + @override + void initState() { + super.initState(); + + censoredItemsWidth = List.generate(widget.items.length, (index) => 25 + Random().nextDouble() * 50).toList(); + } + + @override + Widget build(BuildContext context) { + final tabbar = TabBar( + controller: widget.controller, + isScrollable: widget.scrollable, + physics: const BouncingScrollPhysics(), + // Label + labelStyle: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 15.0, + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + labelColor: Theme.of(context).colorScheme.secondary, + unselectedLabelColor: AppColors.of(context).text.withOpacity(0.65), + // Indicator + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: const EdgeInsets.symmetric(vertical: 8.0), + indicator: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(0.25), + borderRadius: BorderRadius.circular(45.0), + ), + overlayColor: MaterialStateProperty.all(const Color(0x00000000)), + // Tabs + padding: EdgeInsets.zero, + tabs: widget.censored + ? censoredItemsWidth + .map( + (e) => Container( + width: e, + height: 15, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.45), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ) + .toList() + : widget.items, + onTap: widget.onTap, + ); + + return Container( + width: MediaQuery.of(context).size.width, + height: 48.0, + padding: widget.padding, + child: widget.disableFading + ? tabbar + : AnimatedBuilder( + animation: widget.controller.animation!, + builder: (ctx, child) { + // avoid fading over selected tab + return ShaderMask( + shaderCallback: (Rect bounds) { + final Color bg = Theme.of(context).scaffoldBackgroundColor; + final double index = widget.controller.animation!.value; + return LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ + index < 0.2 ? Colors.transparent : bg, + Colors.transparent, + Colors.transparent, + index > widget.controller.length - 1.2 ? Colors.transparent : bg + ], stops: const [ + 0, + 0.1, + 0.9, + 1 + ]).createShader(bounds); + }, + blendMode: BlendMode.dstOut, + child: child); + }, + child: tabbar, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/hero_dialog_route.dart b/filcnaplo_mobile_ui/lib/common/hero_dialog_route.dart new file mode 100755 index 0000000..99789ec --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/hero_dialog_route.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class HeroDialogRoute extends PageRoute { + HeroDialogRoute({required this.builder}) : super(); + + final WidgetBuilder builder; + + @override + bool get opaque => false; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => "livecard"; + + @override + Duration get transitionDuration => const Duration(milliseconds: 250); + + @override + bool get maintainState => true; + + @override + Color get barrierColor => Colors.black38; + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return FadeTransition(opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child); + } + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return builder(context); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/hero_scrollview.dart b/filcnaplo_mobile_ui/lib/common/hero_scrollview.dart new file mode 100755 index 0000000..fe41133 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/hero_scrollview.dart @@ -0,0 +1,133 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; + +class HeroScrollView extends StatefulWidget { + const HeroScrollView( + {Key? key, + required this.child, + required this.title, + required this.icon, + this.italic = false, + this.navBarItems = const [], + this.onClose, + this.iconSize = 64.0, + this.scrollController}) + : super(key: key); + + final Widget child; + final String title; + final IconData? icon; + final List navBarItems; + final VoidCallback? onClose; + final double iconSize; + final ScrollController? scrollController; + final bool italic; + + @override + _HeroScrollViewState createState() => _HeroScrollViewState(); +} + +class _HeroScrollViewState extends State { + late ScrollController _scrollController; + + bool showBarTitle = false; + + @override + void initState() { + super.initState(); + _scrollController = widget.scrollController ?? ScrollController(); + + _scrollController.addListener(() { + if (_scrollController.offset > 42.0) { + if (showBarTitle == false) setState(() => showBarTitle = true); + } else { + if (showBarTitle == true) setState(() => showBarTitle = false); + } + }); + } + + @override + void dispose() { + super.dispose(); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + return NestedScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + SliverAppBar( + pinned: true, + floating: false, + snap: false, + centerTitle: false, + titleSpacing: 0, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + title: AnimatedOpacity( + opacity: showBarTitle ? 1.0 : 0.0, + child: Row( + children: [ + Icon(widget.icon, color: AppColors.of(context).text.withOpacity(.8)), + const SizedBox(width: 8.0), + Expanded( + child: Text( + widget.title.capital(), + overflow: TextOverflow.ellipsis, + maxLines: 2, + style: TextStyle( + color: AppColors.of(context).text, fontWeight: FontWeight.w500, fontStyle: widget.italic ? FontStyle.italic : null), + ), + ), + ], + ), + duration: const Duration(milliseconds: 200)), + leading: BackButton( + color: AppColors.of(context).text, + onPressed: () { + if (widget.onClose != null) { + widget.onClose!(); + } else { + Navigator.of(context).pop(); + } + }), + actions: widget.navBarItems, + expandedHeight: 124.0, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + background: Stack( + children: [ + Center( + child: Icon( + widget.icon, + size: widget.iconSize, + color: AppColors.of(context).text.withOpacity(.15), + ), + ), + Container( + alignment: Alignment.center, + margin: const EdgeInsets.only(top: 82), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + widget.title.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 36.0, + color: AppColors.of(context).text.withOpacity(.9), + fontStyle: widget.italic ? FontStyle.italic : null, + fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + ), + ], + body: widget.child, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/material_action_button.dart b/filcnaplo_mobile_ui/lib/common/material_action_button.dart new file mode 100755 index 0000000..ffcf0de --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/material_action_button.dart @@ -0,0 +1,35 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:flutter/material.dart'; + +class MaterialActionButton extends StatelessWidget { + const MaterialActionButton({ + Key? key, + required this.child, + this.onPressed, + this.backgroundColor, + }) : super(key: key); + + final Widget child; + final Function()? onPressed; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return RawMaterialButton( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + shape: const StadiumBorder(), + child: DefaultTextStyle( + child: child, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + color: backgroundColor != null ? ColorUtils.foregroundColor(backgroundColor!) : null, + ), + ), + fillColor: backgroundColor ?? AppColors.of(context).text.withOpacity(.15), + elevation: 0, + highlightElevation: 0, + onPressed: onPressed, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/new_content_indicator.dart b/filcnaplo_mobile_ui/lib/common/new_content_indicator.dart new file mode 100755 index 0000000..ea8dc87 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/new_content_indicator.dart @@ -0,0 +1,34 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class NewContentIndicator extends StatelessWidget { + const NewContentIndicator({Key? key, this.size = 64.0}) : super(key: key); + + final double size; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + alignment: Alignment.topRight, + width: size, + height: size, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: size / 3.0, + width: size / 3.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: size / 20.0), + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: AppColors.of(context).red, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/panel/panel.dart b/filcnaplo_mobile_ui/lib/common/panel/panel.dart new file mode 100755 index 0000000..6eef23d --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/panel/panel.dart @@ -0,0 +1,135 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class Panel extends StatelessWidget { + const Panel({Key? key, this.child, this.title, this.padding, this.hasShadow = true}) : super(key: key); + + final Widget? child; + final Widget? title; + final EdgeInsetsGeometry? padding; + final bool hasShadow; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Panel Title + if (title != null) PanelTitle(title: title!), + + // Panel Body + if (child != null) + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + color: Theme.of(context).colorScheme.background, + boxShadow: [ + if (hasShadow) + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + padding: padding ?? const EdgeInsets.all(8.0), + child: child, + ), + ], + ); + } +} + +class PanelTitle extends StatelessWidget { + const PanelTitle({Key? key, required this.title}) : super(key: key); + + final Widget title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 14.0, bottom: 8.0), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, color: AppColors.of(context).text.withOpacity(0.65)), + child: title, + ), + ); + } +} + +class PanelHeader extends StatelessWidget { + const PanelHeader({Key? key, required this.padding}) : super(key: key); + + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only(topLeft: Radius.circular(16.0), topRight: Radius.circular(16.0)), + color: Theme.of(context).colorScheme.background, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + ); + } +} + +class PanelBody extends StatelessWidget { + const PanelBody({Key? key, this.child, this.padding}) : super(key: key); + + final Widget? child; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + padding: padding, + child: child, + ); + } +} + +class PanelFooter extends StatelessWidget { + const PanelFooter({Key? key, required this.padding}) : super(key: key); + + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(16.0), bottomRight: Radius.circular(16.0)), + color: Theme.of(context).colorScheme.background, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/panel/panel_action_button.dart b/filcnaplo_mobile_ui/lib/common/panel/panel_action_button.dart new file mode 100755 index 0000000..131e235 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/panel/panel_action_button.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class PanelActionButton extends StatelessWidget { + const PanelActionButton({ + Key? key, + this.onPressed, + this.padding = const EdgeInsets.symmetric(horizontal: 14.0), + this.leading, + this.title, + this.trailing, + }) : super(key: key); + + final void Function()? onPressed; + final EdgeInsetsGeometry padding; + final Widget? leading; + final Widget? title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return RawMaterialButton( + onPressed: onPressed, + padding: padding, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + side: BorderSide(color: Theme.of(context).colorScheme.secondary.withOpacity(.6), width: 2), + ), + child: ListTile( + leading: leading != null + ? Theme( + data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Theme.of(context).colorScheme.secondary)), + child: leading!, + ) + : null, + trailing: trailing, + title: title != null + ? DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w500, fontSize: 15.0), child: title!) + : null, + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/panel/panel_button.dart b/filcnaplo_mobile_ui/lib/common/panel/panel_button.dart new file mode 100755 index 0000000..9c4f485 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/panel/panel_button.dart @@ -0,0 +1,74 @@ +import 'dart:ui'; + +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class PanelButton extends StatelessWidget { + const PanelButton({ + Key? key, + this.onPressed, + this.padding = const EdgeInsets.symmetric(horizontal: 14.0), + this.leading, + this.title, + this.trailing, + this.background = false, + this.trailingDivider = false, + }) : super(key: key); + + final void Function()? onPressed; + final EdgeInsetsGeometry padding; + final Widget? leading; + final Widget? title; + final Widget? trailing; + final bool background; + final bool trailingDivider; + + @override + Widget build(BuildContext context) { + final button = RawMaterialButton( + onPressed: onPressed, + padding: padding, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + fillColor: background ? Colors.white.withOpacity(Theme.of(context).brightness == Brightness.light ? .35 : .2) : null, + child: ListTile( + leading: leading != null + ? Theme( + data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Theme.of(context).colorScheme.secondary)), + child: leading!, + ) + : null, + trailing: trailingDivider + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(right: 6.0), + width: 2.0, + height: 32.0, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.15), + borderRadius: BorderRadius.circular(45.0), + ), + ), + if (trailing != null) trailing!, + ], + ) + : trailing, + title: title != null + ? DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, fontSize: 16.0), child: title!) + : null, + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + + if (!background) return button; + + return BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 12.0, + sigmaY: 12.0, + ), + child: button); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/profile_image/profile_button.dart b/filcnaplo_mobile_ui/lib/common/profile_image/profile_button.dart new file mode 100755 index 0000000..50e888e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/profile_image/profile_button.dart @@ -0,0 +1,50 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:sliding_sheet/sliding_sheet.dart'; + +class ProfileButton extends StatelessWidget { + const ProfileButton({Key? key, required this.child}) : super(key: key); + + final ProfileImage child; + + @override + Widget build(BuildContext context) { + final bool pMode = Provider.of(context, listen: false).presentationMode; + + return ProfileImage( + backgroundColor: !pMode ? child.backgroundColor : Theme.of(context).colorScheme.secondary, + heroTag: child.heroTag, + key: child.key, + name: !pMode ? child.name : "Béla", + radius: child.radius, + badge: child.badge, + role: child.role, + profilePictureString: child.profilePictureString, + onTap: () { + showSlidingBottomSheet( + context, + useRootNavigator: true, + builder: (context) => SlidingSheetDialog( + color: Theme.of(context).scaffoldBackgroundColor, + duration: const Duration(milliseconds: 400), + scrollSpec: const ScrollSpec.bouncingScroll(), + snapSpec: const SnapSpec( + snap: true, + snappings: [1.0], + positioning: SnapPositioning.relativeToSheetHeight, + ), + cornerRadius: 16, + cornerRadiusOnFullscreen: 0, + builder: (context, state) => Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: const SettingsScreen(), + ), + ), + ); + }, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/profile_image/profile_image.dart b/filcnaplo_mobile_ui/lib/common/profile_image/profile_image.dart new file mode 100755 index 0000000..8278a01 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/profile_image/profile_image.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +import 'package:filcnaplo/models/user.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/new_content_indicator.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/color.dart'; + +class ProfileImage extends StatefulWidget { + const ProfileImage({ + Key? key, + this.onTap, + this.name, + this.backgroundColor, + this.radius = 20.0, + this.heroTag, + this.badge = false, + this.role = Role.student, + this.censored = false, + this.profilePictureString = "", + }) : super(key: key); + + final void Function()? onTap; + final String? name; + final Color? backgroundColor; + final double radius; + final String? heroTag; + final bool badge; + final Role? role; + final bool censored; + final String profilePictureString; + + @override + State createState() => _ProfileImageState(); +} + +class _ProfileImageState extends State { + Image? profilePicture; + String? profPicSaved; + + @override + void initState() { + super.initState(); + updatePic(); + } + + void updatePic() { + profilePicture = widget.profilePictureString != "" + ? Image.memory(const Base64Decoder().convert(widget.profilePictureString), fit: BoxFit.scaleDown, gaplessPlayback: true) + : null; + profPicSaved = widget.profilePictureString; + } + + @override + Widget build(BuildContext context) { + if (profPicSaved != widget.profilePictureString) updatePic(); + + if (widget.heroTag == null) { + return buildWithoutHero(context); + } else { + return buildWithHero(context); + } + } + + Widget buildWithoutHero(BuildContext context) { + Color color = ColorUtils.foregroundColor(widget.backgroundColor ?? Theme.of(context).scaffoldBackgroundColor); + Color roleColor; + + if (Theme.of(context).brightness == Brightness.light) { + roleColor = const Color(0xFF444444); + } else { + roleColor = const Color(0xFF555555); + } + + return Stack( + alignment: Alignment.center, + children: [ + Material( + clipBehavior: Clip.hardEdge, + shape: const CircleBorder(), + color: widget.backgroundColor ?? AppColors.of(context).text.withOpacity(.15), + child: InkWell( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: widget.radius * 2, + width: widget.radius * 2, + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + child: widget.name != null && (widget.name?.trim().length ?? 0) > 0 + ? Center( + child: widget.censored + ? Container( + width: 15, + height: 15, + decoration: BoxDecoration( + color: color.withOpacity(.5), + borderRadius: BorderRadius.circular(8.0), + ), + ) + : profilePicture ?? + Text( + (widget.name?.trim().length ?? 0) > 0 ? (widget.name ?? "?").trim()[0] : "?", + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 18.0 * (widget.radius / 20.0), + ), + ), + ) + : Container(), + ), + ), + ), + + // Role indicator + if (widget.role == Role.parent) + SizedBox( + height: widget.radius * 2, + width: widget.radius * 2, + child: Container( + alignment: Alignment.bottomRight, + child: Icon(Icons.shield, color: roleColor, size: widget.radius / 1.3), + ), + ), + ], + ); + } + + Widget buildWithHero(BuildContext context) { + Color color = ColorUtils.foregroundColor(widget.backgroundColor ?? Theme.of(context).scaffoldBackgroundColor); + Color roleColor; + + if (Theme.of(context).brightness == Brightness.light) { + roleColor = const Color(0xFF444444); + } else { + roleColor = const Color(0xFF555555); + } + + Widget child = FittedBox( + fit: BoxFit.fitHeight, + child: Text( + (widget.name?.trim().length ?? 0) > 0 ? (widget.name ?? "?").trim()[0] : "?", + style: TextStyle( + color: color, + fontWeight: FontWeight.w600, + fontSize: 18.0 * (widget.radius / 20.0), + ), + ), + ); + + return SizedBox( + height: widget.radius * 2, + width: widget.radius * 2, + child: Stack( + alignment: Alignment.center, + children: [ + if (widget.name != null && (widget.name?.trim().length ?? 0) > 0) + Hero( + tag: widget.heroTag! + "background", + transitionOnUserGestures: true, + child: Material( + clipBehavior: Clip.hardEdge, + shape: const CircleBorder(), + color: profilePicture != null ? Colors.transparent : widget.backgroundColor ?? AppColors.of(context).text.withOpacity(.15), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: widget.radius * 2, + width: widget.radius * 2, + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + child: profilePicture, + ), + ), + ), + Hero( + tag: widget.heroTag! + "child", + transitionOnUserGestures: true, + child: Material( + clipBehavior: Clip.hardEdge, + shape: profilePicture != null ? const CircleBorder() : null, + child: profilePicture ?? child, + type: MaterialType.transparency, + ), + ), + + // Badge + if (widget.badge) + Hero( + tag: widget.heroTag! + "new_content_indicator", + child: NewContentIndicator(size: widget.radius * 2), + ), + + // Role indicator + if (widget.role == Role.parent) + Hero( + tag: widget.heroTag! + "role_indicator", + child: FittedBox( + fit: BoxFit.fitHeight, + child: SizedBox( + height: widget.radius * 2, + width: widget.radius * 2, + child: Container( + alignment: Alignment.bottomRight, + child: Icon(Icons.shield, color: roleColor, size: widget.radius / 1.3), + ), + ), + ), + ), + + Material( + color: Colors.transparent, + clipBehavior: Clip.hardEdge, + shape: const CircleBorder(), + child: InkWell( + onTap: widget.onTap, + child: SizedBox( + height: widget.radius * 2, + width: widget.radius * 2, + ), + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/progress_bar.dart b/filcnaplo_mobile_ui/lib/common/progress_bar.dart new file mode 100755 index 0000000..33179a1 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/progress_bar.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +class ProgressBar extends StatelessWidget { + const ProgressBar({Key? key, required this.value, this.backgroundColor}) : super(key: key); + + final double value; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Background + Container( + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.light ? Colors.black.withOpacity(0.1) : Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(45.0), + ), + width: double.infinity, + height: 8.0, + ), + + // Slider + AnimatedContainer( + duration: const Duration(milliseconds: 500), + width: double.infinity, + child: CustomPaint( + painter: ProgressPainter( + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.secondary, + height: 8.0, + value: value.clamp(0, 1), + ), + ), + ) + ], + ); + } +} + +class ProgressPainter extends CustomPainter { + ProgressPainter({required this.height, required this.value, required this.backgroundColor}); + + final double height; + final double value; + final Color backgroundColor; + + @override + void paint(Canvas canvas, Size size) { + double width = size.width * value; + + if (width <= 0) return; + + // Slider + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, width, height), + const Radius.circular(45.0), + ), + Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(ProgressPainter oldDelegate) { + return value != oldDelegate.value || height != oldDelegate.height || backgroundColor != oldDelegate.backgroundColor; + } +} diff --git a/filcnaplo_mobile_ui/lib/common/screens.i18n.dart b/filcnaplo_mobile_ui/lib/common/screens.i18n.dart new file mode 100755 index 0000000..3145432 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/screens.i18n.dart @@ -0,0 +1,33 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension ScreensLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "home": "Home", + "grades": "Grades", + "timetable": "Timetable", + "messages": "Messages", + "absences": "Absences", + }, + "hu_hu": { + "home": "Kezdőlap", + "grades": "Jegyek", + "timetable": "Órarend", + "messages": "Üzenetek", + "absences": "Hiányok", + }, + "de_de": { + "home": "Zuhause", + "grades": "Noten", + "timetable": "Zeitplan", + "messages": "Mitteilungen", + "absences": "Fehlen", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/sliding_bottom_sheet.dart b/filcnaplo_mobile_ui/lib/common/sliding_bottom_sheet.dart new file mode 100755 index 0000000..b928f09 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/sliding_bottom_sheet.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:sliding_sheet/sliding_sheet.dart' as ss; + +void showSlidingBottomSheet({required Widget child, required BuildContext context}) => ss.showSlidingBottomSheet(context, + useRootNavigator: true, + builder: (context) => ss.SlidingSheetDialog( + cornerRadius: 16, + cornerRadiusOnFullscreen: 0, + avoidStatusBar: true, + color: Theme.of(context).colorScheme.background, + duration: const Duration(milliseconds: 400), + snapSpec: const ss.SnapSpec( + snap: true, + snappings: [0.5, 1.0], + positioning: ss.SnapPositioning.relativeToAvailableSpace, + ), + headerBuilder: (context, state) { + return Material( + color: Theme.of(context).colorScheme.background, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(12.0), + ), + height: 4.0, + width: 60.0, + margin: const EdgeInsets.all(12.0), + ), + ], + ), + ); + }, + builder: (context, state) { + return Material( + color: Theme.of(context).colorScheme.background, + child: Padding(padding: const EdgeInsets.fromLTRB(12.0, 0, 12.0, 8.0), child: child), + ); + }, + )); diff --git a/filcnaplo_mobile_ui/lib/common/system_chrome.dart b/filcnaplo_mobile_ui/lib/common/system_chrome.dart new file mode 100755 index 0000000..725b44c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/system_chrome.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void setSystemChrome(BuildContext context) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom]); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light, + systemNavigationBarColor: Theme.of(context).bottomNavigationBarTheme.backgroundColor, + systemNavigationBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarBrightness: Platform.isIOS ? Theme.of(context).brightness : null, + )); +} diff --git a/filcnaplo_mobile_ui/lib/common/trend_display.dart b/filcnaplo_mobile_ui/lib/common/trend_display.dart new file mode 100755 index 0000000..c2697e1 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/trend_display.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_widget.dart'; + +class TrendDisplay extends StatelessWidget { + const TrendDisplay({Key? key, required this.current, required this.previous, this.padding}) : super(key: key); + + final T current; + final T previous; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + const upIcon = "▲"; + const downIcon = "▼"; + final upColor = Colors.lightGreenAccent.shade700; + const downColor = Colors.redAccent; + + Color color; + String icon; + + double percentage; + + if (previous > 0) { + percentage = (current - previous) * 100.0; + } else { + percentage = 0.0; + } + + final String percentageText = percentage.abs().toStringAsFixed(1).replaceAll('.', I18n.of(context).locale.languageCode != 'en' ? ',' : '.'); + + if (!percentage.isNegative) { + color = upColor; + icon = upIcon; + } else { + color = downColor; + icon = downIcon; + } + + if (percentage == 0) { + return const SizedBox(); + } + + return Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 2.0), + child: Text( + icon, + style: TextStyle(fontSize: 18.0, color: color), + ), + ), + Text("$percentageText%", style: TextStyle(color: color)), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/viewable.dart b/filcnaplo_mobile_ui/lib/common/viewable.dart new file mode 100755 index 0000000..37a2d28 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/viewable.dart @@ -0,0 +1,979 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart' show kMinFlingVelocity; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +double valueFromPercentageInRange({required final double min, max, percentage}) { + return percentage * (max - min) + min; +} + +double percentageFromValueInRange({required final double min, max, value}) { + return (value - min) / (max - min); +} + +const double _kOpenScale = 1.025; + +const Color _borderColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFA9A9AF), + darkColor: Color(0xFF57585A), +); + +typedef _DismissCallback = void Function( + BuildContext context, + double scale, + double opacity, +); + +typedef ViewablePreviewBuilder = Widget Function( + BuildContext context, + Animation animation, + Widget child, +); + +typedef _ViewablePreviewBuilderChildless = Widget Function( + BuildContext context, + Animation animation, +); + +Rect _getRect(GlobalKey globalKey) { + assert(globalKey.currentContext != null); + final RenderBox renderBoxContainer = globalKey.currentContext!.findRenderObject()! as RenderBox; + final Offset containerOffset = renderBoxContainer.localToGlobal( + renderBoxContainer.paintBounds.topLeft, + ); + return containerOffset & renderBoxContainer.paintBounds.size; +} + +enum _ViewableLocation { + center, + left, + right, +} + +class Viewable extends StatefulWidget { + const Viewable({ + Key? key, + required this.view, + required this.tile, + this.actions = const [], + this.previewBuilder, + }) : super(key: key); + + final Widget tile; + final Widget view; + + final List actions; + + final ViewablePreviewBuilder? previewBuilder; + + @override + State createState() => _ViewableState(); +} + +class _ViewableState extends State with TickerProviderStateMixin { + final GlobalKey _childGlobalKey = GlobalKey(); + bool _childHidden = false; + + late AnimationController _openController; + Rect? _decoyChildEndRect; + OverlayEntry? _lastOverlayEntry; + _ViewableRoute? _route; + + @override + void initState() { + super.initState(); + _openController = AnimationController( + duration: const Duration(milliseconds: 100), + vsync: this, + ); + _openController.addStatusListener(_onDecoyAnimationStatusChange); + } + + _ViewableLocation get _contextMenuLocation { + final Rect childRect = _getRect(_childGlobalKey); + final double screenWidth = MediaQuery.of(context).size.width; + + final double center = screenWidth / 2; + final bool centerDividesChild = childRect.left < center && childRect.right > center; + final double distanceFromCenter = (center - childRect.center.dx).abs(); + if (centerDividesChild && distanceFromCenter <= childRect.width / 4) { + return _ViewableLocation.center; + } + + if (childRect.center.dx > center) { + return _ViewableLocation.right; + } + + return _ViewableLocation.left; + } + + void _openContextMenu() { + setState(() { + _childHidden = true; + }); + + _route = _ViewableRoute( + actions: widget.actions, + barrierLabel: 'Dismiss', + filter: ui.ImageFilter.blur( + sigmaX: 5.0, + sigmaY: 5.0, + ), + contextMenuLocation: _contextMenuLocation, + previousChildRect: _decoyChildEndRect!, + builder: (BuildContext context, Animation animation) { + return ClipRRect( + borderRadius: BorderRadius.circular(16.0), + child: Material( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(16.0), + child: Stack( + children: [ + Opacity( + opacity: animation.status == AnimationStatus.forward + ? Curves.easeOutCirc.transform(animation.value) + : Curves.easeInCirc.transform(animation.value), + child: widget.view, + ), + Opacity( + opacity: 1 - + (animation.status == AnimationStatus.forward + ? Curves.easeOutCirc.transform(animation.value) + : Curves.easeInCirc.transform(animation.value)), + child: widget.tile, + ), + ], + ), + ), + ); + }, + ); + Navigator.of(context, rootNavigator: true).push(_route!); + _route!.animation!.addStatusListener(_routeAnimationStatusListener); + } + + void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) { + switch (animationStatus) { + case AnimationStatus.dismissed: + if (_route == null) { + setState(() { + _childHidden = false; + }); + } + _lastOverlayEntry?.remove(); + _lastOverlayEntry = null; + break; + + case AnimationStatus.completed: + setState(() { + _childHidden = true; + }); + _openContextMenu(); + + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _lastOverlayEntry?.remove(); + _lastOverlayEntry = null; + _openController.reset(); + }); + break; + + case AnimationStatus.forward: + case AnimationStatus.reverse: + return; + } + } + + void _routeAnimationStatusListener(AnimationStatus status) { + if (status != AnimationStatus.dismissed) { + return; + } + setState(() { + _childHidden = false; + }); + _route!.animation!.removeStatusListener(_routeAnimationStatusListener); + _route = null; + } + + void _onTap() { + _onTapDown(TapDownDetails(), anim: false); + } + + void _onTapDown(TapDownDetails details, {anim = true}) { + setState(() { + _childHidden = true; + }); + + final Rect childRect = _getRect(_childGlobalKey); + _decoyChildEndRect = Rect.fromCenter( + center: childRect.center, + width: childRect.width * _kOpenScale, + height: childRect.height * _kOpenScale, + ); + + _lastOverlayEntry = OverlayEntry( + builder: (BuildContext context) { + return _DecoyChild( + beginRect: childRect, + controller: _openController, + endRect: _decoyChildEndRect, + child: widget.tile, + ); + }, + ); + Overlay.of(context, rootOverlay: true).insert(_lastOverlayEntry!); + _openController.forward(from: anim ? 0.0 : 1.0); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: TickerMode( + enabled: !_childHidden, + child: Opacity( + key: _childGlobalKey, + opacity: _childHidden ? 0.0 : 1.0, + child: widget.tile, + ), + ), + ); + } + + @override + void dispose() { + _openController.dispose(); + super.dispose(); + } +} + +class _DecoyChild extends StatefulWidget { + const _DecoyChild({ + Key? key, + this.beginRect, + required this.controller, + this.endRect, + this.child, + }) : super(key: key); + + final Rect? beginRect; + final AnimationController controller; + final Rect? endRect; + final Widget? child; + + @override + _DecoyChildState createState() => _DecoyChildState(); +} + +class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin { + static const Color _lightModeMaskColor = Color(0xFF888888); + static const Color _masklessColor = Color(0xFFFFFFFF); + + final GlobalKey _childGlobalKey = GlobalKey(); + late Animation _mask; + late Animation _rect; + + @override + void initState() { + super.initState(); + + _mask = _OnOffAnimation( + controller: widget.controller, + onValue: _lightModeMaskColor, + offValue: _masklessColor, + intervalOn: 0.0, + intervalOff: 0.5, + ); + + final Rect midRect = widget.beginRect!.deflate( + widget.beginRect!.width * (_kOpenScale - 1.0) / 2, + ); + _rect = TweenSequence(>[ + TweenSequenceItem( + tween: RectTween( + begin: widget.beginRect, + end: midRect, + ).chain(CurveTween(curve: Curves.easeInOutCubic)), + weight: 1.0, + ), + TweenSequenceItem( + tween: RectTween( + begin: midRect, + end: widget.endRect, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + weight: 1.0, + ), + ]).animate(widget.controller); + _rect.addListener(_rectListener); + } + + void _rectListener() { + if (widget.controller.value < 0.5) { + return; + } + HapticFeedback.selectionClick(); + _rect.removeListener(_rectListener); + } + + @override + void dispose() { + _rect.removeListener(_rectListener); + super.dispose(); + } + + Widget _buildAnimation(BuildContext context, Widget? child) { + final Color color = widget.controller.status == AnimationStatus.reverse ? _masklessColor : _mask.value; + return Positioned.fromRect( + rect: _rect.value!, + child: ShaderMask( + key: _childGlobalKey, + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, color], + ).createShader(bounds); + }, + child: widget.child, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + AnimatedBuilder( + builder: _buildAnimation, + animation: widget.controller, + ), + ], + ); + } +} + +class _ViewableRoute extends PopupRoute { + _ViewableRoute({ + required List actions, + required _ViewableLocation contextMenuLocation, + this.barrierLabel, + _ViewablePreviewBuilderChildless? builder, + ui.ImageFilter? filter, + required Rect previousChildRect, + RouteSettings? settings, + }) : _actions = actions, + _builder = builder, + _contextMenuLocation = contextMenuLocation, + _previousChildRect = previousChildRect, + super( + filter: filter, + settings: settings, + ); + + static const Color _kModalBarrierColor = Color(0x6604040F); + + static const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335); + + final List _actions; + final _ViewablePreviewBuilderChildless? _builder; + final GlobalKey _childGlobalKey = GlobalKey(); + final _ViewableLocation _contextMenuLocation; + bool _externalOffstage = false; + bool _internalOffstage = false; + Orientation? _lastOrientation; + + final Rect _previousChildRect; + double? _scale = 1.0; + final GlobalKey _sheetGlobalKey = GlobalKey(); + + static final CurveTween _curve = CurveTween( + curve: Curves.easeOutBack, + ); + static final CurveTween _curveReverse = CurveTween( + curve: Curves.easeInBack, + ); + static final RectTween _rectTween = RectTween(); + static final Animatable _rectAnimatable = _rectTween.chain(_curve); + static final RectTween _rectTweenReverse = RectTween(); + static final Animatable _rectAnimatableReverse = _rectTweenReverse.chain( + _curveReverse, + ); + static final RectTween _sheetRectTween = RectTween(); + final Animatable _sheetRectAnimatable = _sheetRectTween.chain( + _curve, + ); + final Animatable _sheetRectAnimatableReverse = _sheetRectTween.chain( + _curveReverse, + ); + static final Tween _sheetScaleTween = Tween(); + static final Animatable _sheetScaleAnimatable = _sheetScaleTween.chain( + _curve, + ); + static final Animatable _sheetScaleAnimatableReverse = _sheetScaleTween.chain( + _curveReverse, + ); + final Tween _opacityTween = Tween(begin: 0.0, end: 1.0); + late Animation _sheetOpacity; + + @override + final String? barrierLabel; + + @override + Color get barrierColor => _kModalBarrierColor; + + @override + bool get barrierDismissible => true; + + @override + bool get semanticsDismissible => false; + + @override + Duration get transitionDuration => _kModalPopupTransitionDuration; + + static Rect _getScaledRect(GlobalKey globalKey, double scale) { + final Rect childRect = _getRect(globalKey); + final Size sizeScaled = childRect.size * scale; + final Offset offsetScaled = Offset( + childRect.left + (childRect.size.width - sizeScaled.width) / 2, + childRect.top + (childRect.size.height - sizeScaled.height) / 2, + ); + return offsetScaled & sizeScaled; + } + + static AlignmentDirectional getSheetAlignment(_ViewableLocation contextMenuLocation) { + switch (contextMenuLocation) { + case _ViewableLocation.center: + return AlignmentDirectional.topCenter; + case _ViewableLocation.right: + return AlignmentDirectional.topEnd; + case _ViewableLocation.left: + return AlignmentDirectional.topStart; + } + } + + static Rect _getSheetRectBegin(Orientation? orientation, _ViewableLocation contextMenuLocation, Rect childRect, Rect sheetRect) { + switch (contextMenuLocation) { + case _ViewableLocation.center: + final Offset target = orientation == Orientation.portrait ? childRect.bottomCenter : childRect.topCenter; + final Offset centered = target - Offset(sheetRect.width / 2, 0.0); + return centered & sheetRect.size; + case _ViewableLocation.right: + final Offset target = orientation == Orientation.portrait ? childRect.bottomRight : childRect.topRight; + return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size; + case _ViewableLocation.left: + final Offset target = orientation == Orientation.portrait ? childRect.bottomLeft : childRect.topLeft; + return target & sheetRect.size; + } + } + + void _onDismiss(BuildContext context, double scale, double opacity) { + _scale = scale; + _opacityTween.end = opacity; + _sheetOpacity = _opacityTween.animate(CurvedAnimation( + parent: animation!, + curve: const Interval(0.9, 1.0), + )); + Navigator.of(context).pop(); + } + + void _updateTweenRects() { + final Rect childRect = _scale == null ? _getRect(_childGlobalKey) : _getScaledRect(_childGlobalKey, _scale!); + _rectTween.begin = _previousChildRect; + _rectTween.end = childRect; + + final Rect childRectOriginal = Rect.fromCenter( + center: _previousChildRect.center, + width: _previousChildRect.width / _kOpenScale, + height: _previousChildRect.height / _kOpenScale, + ); + + final Rect sheetRect = _getRect(_sheetGlobalKey); + final Rect sheetRectBegin = _getSheetRectBegin( + _lastOrientation, + _contextMenuLocation, + childRectOriginal, + sheetRect, + ); + _sheetRectTween.begin = sheetRectBegin; + _sheetRectTween.end = sheetRect; + _sheetScaleTween.begin = 0.0; + _sheetScaleTween.end = _scale; + + _rectTweenReverse.begin = childRectOriginal; + _rectTweenReverse.end = childRect; + } + + void _setOffstageInternally() { + super.offstage = _externalOffstage || _internalOffstage; + + changedInternalState(); + } + + @override + bool didPop(T? result) { + _updateTweenRects(); + return super.didPop(result); + } + + @override + set offstage(bool value) { + _externalOffstage = value; + _setOffstageInternally(); + } + + @override + TickerFuture didPush() { + _internalOffstage = true; + _setOffstageInternally(); + + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _updateTweenRects(); + _internalOffstage = false; + _setOffstageInternally(); + }); + return super.didPush(); + } + + @override + Animation createAnimation() { + final Animation animation = super.createAnimation(); + _sheetOpacity = _opacityTween.animate(CurvedAnimation( + parent: animation, + curve: Curves.linear, + )); + return animation; + } + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return Container(); + } + + @override + Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + return OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + _lastOrientation = orientation; + + if (!animation.isCompleted) { + final bool reverse = animation.status == AnimationStatus.reverse; + final Rect rect = reverse ? _rectAnimatableReverse.evaluate(animation)! : _rectAnimatable.evaluate(animation)!; + final Rect sheetRect = reverse ? _sheetRectAnimatableReverse.evaluate(animation)! : _sheetRectAnimatable.evaluate(animation)!; + final double sheetScale = reverse ? _sheetScaleAnimatableReverse.evaluate(animation) : _sheetScaleAnimatable.evaluate(animation); + return Stack( + children: [ + Positioned.fromRect( + rect: sheetRect, + child: FadeTransition( + opacity: _sheetOpacity, + child: Transform.scale( + alignment: getSheetAlignment(_contextMenuLocation), + scale: sheetScale, + child: _ViewableSheet( + key: _sheetGlobalKey, + actions: _actions, + ), + ), + ), + ), + Positioned.fromRect( + key: _childGlobalKey, + rect: rect, + child: _builder!(context, animation), + ), + ], + ); + } + + return _ContextMenuRouteStatic( + actions: _actions, + childGlobalKey: _childGlobalKey, + contextMenuLocation: _contextMenuLocation, + onDismiss: _onDismiss, + orientation: orientation, + sheetGlobalKey: _sheetGlobalKey, + child: _builder!(context, animation), + ); + }, + ); + } +} + +class _ContextMenuRouteStatic extends StatefulWidget { + const _ContextMenuRouteStatic({ + Key? key, + this.actions, + required this.child, + this.childGlobalKey, + required this.contextMenuLocation, + this.onDismiss, + required this.orientation, + this.sheetGlobalKey, + }) : super(key: key); + + final List? actions; + final Widget child; + final GlobalKey? childGlobalKey; + final _ViewableLocation contextMenuLocation; + final _DismissCallback? onDismiss; + final Orientation orientation; + final GlobalKey? sheetGlobalKey; + + @override + _ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState(); +} + +class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> with TickerProviderStateMixin { + static const double _kMinScale = 0.8; + + static const double _kSheetScaleThreshold = 0.9; + static const double _kPadding = 20.0; + static const double _kDamping = 400.0; + static const Duration _kMoveControllerDuration = Duration(milliseconds: 600); + + late Offset _dragOffset; + double _lastScale = 1.0; + late AnimationController _moveController; + late AnimationController _sheetController; + late Animation _moveAnimation; + late Animation _sheetScaleAnimation; + late Animation _sheetOpacityAnimation; + + static double _getScale(Orientation orientation, double maxDragDistance, double dy) { + final double dyDirectional = dy <= 0.0 ? dy : -dy; + return math.max( + _kMinScale, + (maxDragDistance + dyDirectional) / maxDragDistance, + ); + } + + void _onPanStart(DragStartDetails details) { + _moveController.value = 1.0; + _setDragOffset(Offset.zero); + } + + void _onPanUpdate(DragUpdateDetails details) { + _setDragOffset(_dragOffset + details.delta); + } + + void _onPanEnd(DragEndDetails details) { + if (details.velocity.pixelsPerSecond.dy.abs() >= kMinFlingVelocity) { + final bool flingIsAway = details.velocity.pixelsPerSecond.dy > 0; + final double finalPosition = flingIsAway ? _moveAnimation.value.dy + 100.0 : 0.0; + + if (flingIsAway && _sheetController.status != AnimationStatus.forward) { + _sheetController.forward(); + } else if (!flingIsAway && _sheetController.status != AnimationStatus.reverse) { + _sheetController.reverse(); + } + + _moveAnimation = Tween( + begin: Offset(0.0, _moveAnimation.value.dy), + end: Offset(0.0, finalPosition), + ).animate(_moveController); + _moveController.reset(); + _moveController.duration = const Duration( + milliseconds: 64, + ); + _moveController.forward(); + _moveController.addStatusListener(_flingStatusListener); + return; + } + + if (_lastScale == _kMinScale) { + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + return; + } + + _moveController.addListener(_moveListener); + _moveController.reverse(); + } + + void _moveListener() { + if (_lastScale > _kSheetScaleThreshold) { + _moveController.removeListener(_moveListener); + if (_sheetController.status != AnimationStatus.dismissed) { + _sheetController.reverse(); + } + } + } + + void _flingStatusListener(AnimationStatus status) { + if (status != AnimationStatus.completed) { + return; + } + + _moveController.duration = _kMoveControllerDuration; + + _moveController.removeStatusListener(_flingStatusListener); + + if (_moveAnimation.value.dy == 0.0) { + return; + } + widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value); + } + + Alignment _getChildAlignment(Orientation orientation, _ViewableLocation contextMenuLocation) { + switch (contextMenuLocation) { + case _ViewableLocation.center: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; + case _ViewableLocation.right: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topLeft; + case _ViewableLocation.left: + return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight; + } + } + + void _setDragOffset(Offset dragOffset) { + final double endX = _kPadding * dragOffset.dx / _kDamping; + final double endY = dragOffset.dy >= 0.0 ? dragOffset.dy : _kPadding * dragOffset.dy / _kDamping; + setState(() { + _dragOffset = dragOffset; + _moveAnimation = Tween( + begin: Offset.zero, + end: Offset( + endX.clamp(-_kPadding, _kPadding), + endY, + ), + ).animate( + CurvedAnimation( + parent: _moveController, + curve: Curves.elasticIn, + ), + ); + + if (_lastScale <= _kSheetScaleThreshold && _sheetController.status != AnimationStatus.forward && _sheetScaleAnimation.value != 0.0) { + _sheetController.forward(); + } else if (_lastScale > _kSheetScaleThreshold && _sheetController.status != AnimationStatus.reverse && _sheetScaleAnimation.value != 1.0) { + _sheetController.reverse(); + } + }); + } + + List _getChildren(Orientation orientation, _ViewableLocation contextMenuLocation) { + final Expanded child = Expanded( + child: Align( + alignment: _getChildAlignment( + widget.orientation, + widget.contextMenuLocation, + ), + child: AnimatedBuilder( + animation: _moveController, + builder: _buildChildAnimation, + child: widget.child, + ), + ), + ); + const SizedBox spacer = SizedBox( + width: _kPadding, + height: _kPadding, + ); + final sheet = AnimatedBuilder( + animation: _sheetController, + builder: _buildSheetAnimation, + child: _ViewableSheet( + key: widget.sheetGlobalKey, + actions: widget.actions!, + ), + ); + + switch (contextMenuLocation) { + case _ViewableLocation.center: + return [child, spacer, sheet]; + case _ViewableLocation.right: + return orientation == Orientation.portrait ? [child, spacer, sheet] : [sheet, spacer, child]; + case _ViewableLocation.left: + return [child, spacer, sheet]; + } + } + + Widget _buildSheetAnimation(BuildContext context, Widget? child) { + return Transform.scale( + alignment: _ViewableRoute.getSheetAlignment(widget.contextMenuLocation), + scale: _sheetScaleAnimation.value, + child: FadeTransition( + opacity: _sheetOpacityAnimation, + child: child, + ), + ); + } + + Widget _buildChildAnimation(BuildContext context, Widget? child) { + _lastScale = _getScale( + widget.orientation, + MediaQuery.of(context).size.height, + _moveAnimation.value.dy, + ); + return Transform.scale( + key: widget.childGlobalKey, + scale: _lastScale, + child: child, + ); + } + + Widget _buildAnimation(BuildContext context, Widget? child) { + return Transform.translate( + offset: _moveAnimation.value, + child: child, + ); + } + + @override + void initState() { + super.initState(); + _moveController = AnimationController( + duration: _kMoveControllerDuration, + value: 1.0, + vsync: this, + ); + _sheetController = AnimationController( + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 200), + vsync: this, + ); + _sheetScaleAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate( + CurvedAnimation( + parent: _sheetController, + curve: Curves.linear, + reverseCurve: Curves.easeInBack, + ), + ); + _sheetOpacityAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate(_sheetController); + _setDragOffset(Offset.zero); + } + + @override + void dispose() { + _moveController.dispose(); + _sheetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List children = _getChildren( + widget.orientation, + widget.contextMenuLocation, + ); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(_kPadding), + child: Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onPanEnd: _onPanEnd, + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + child: AnimatedBuilder( + animation: _moveController, + builder: _buildAnimation, + child: widget.orientation == Orientation.portrait + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ), + ), + ), + ); + } +} + +class _ViewableSheet extends StatelessWidget { + const _ViewableSheet({ + Key? key, + required this.actions, + }) : super(key: key); + + final List actions; + + List getChildren(BuildContext context) { + if (actions.isEmpty) return []; + + final Widget menu = Expanded( + child: IntrinsicHeight( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(13.0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + actions.first, + for (Widget action in actions.skip(1)) + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: CupertinoDynamicColor.resolve(_borderColor, context), + width: 0.5, + )), + ), + position: DecorationPosition.foreground, + child: action, + ), + ], + ), + ), + ), + ); + + return [menu]; + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: getChildren(context), + ); + } +} + +class _OnOffAnimation extends CompoundAnimation { + _OnOffAnimation({ + required AnimationController controller, + required T onValue, + required T offValue, + required double intervalOn, + required double intervalOff, + }) : _offValue = offValue, + assert(intervalOn >= 0.0 && intervalOn <= 1.0), + assert(intervalOff >= 0.0 && intervalOff <= 1.0), + assert(intervalOn <= intervalOff), + super( + first: Tween(begin: offValue, end: onValue).animate( + CurvedAnimation( + parent: controller, + curve: Interval(intervalOn, intervalOn), + ), + ), + next: Tween(begin: onValue, end: offValue).animate( + CurvedAnimation( + parent: controller, + curve: Interval(intervalOff, intervalOff), + ), + ), + ); + + final T _offValue; + + @override + T get value => next.value == _offValue ? next.value : first.value; +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_display.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_display.dart new file mode 100755 index 0000000..72be870 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_display.dart @@ -0,0 +1,50 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class AbsenceDisplay extends StatelessWidget { + const AbsenceDisplay(this.excused, this.unexcused, this.pending, {Key? key}) : super(key: key); + + final int excused; + final int unexcused; + final int pending; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(top: 5.0), + // padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0), + // decoration: BoxDecoration( + // color: Theme.of(context).scaffoldBackgroundColor.withOpacity(.2), + // borderRadius: BorderRadius.circular(12.0), + // ), + child: Row(children: [ + if (excused > 0) + Icon( + FeatherIcons.check, + size: 16.0, + color: AppColors.of(context).green, + ), + if (excused > 0) const SizedBox(width: 2.0), + if (excused > 0) Text(excused.toString(), style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)), + if (excused > 0 && pending > 0) const SizedBox(width: 6.0), + if (pending > 0) + Icon( + FeatherIcons.slash, + size: 14.0, + color: AppColors.of(context).orange, + ), + if (pending > 0) const SizedBox(width: 3.0), + if (pending > 0) Text(pending.toString(), style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)), + if (unexcused > 0 && pending > 0) const SizedBox(width: 3.0), + if (unexcused > 0) + Icon( + FeatherIcons.x, + size: 18.0, + color: AppColors.of(context).red, + ), + if (unexcused > 0) Text(unexcused.toString(), style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)), + ]), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_subject_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_subject_tile.dart new file mode 100755 index 0000000..544fb76 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_subject_tile.dart @@ -0,0 +1,80 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_display.dart'; +import 'package:flutter/material.dart'; + +class AbsenceSubjectTile extends StatelessWidget { + const AbsenceSubjectTile(this.subject, {Key? key, this.percentage = 0.0, this.excused = 0, this.unexcused = 0, this.pending = 0, this.onTap}) + : super(key: key); + + final Subject subject; + final void Function()? onTap; + final double percentage; + + final int excused; + final int unexcused; + final int pending; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: ListTile( + // minLeadingWidth: 32.0, + dense: true, + contentPadding: const EdgeInsets.only(left: 8.0, right: 6.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + visualDensity: VisualDensity.compact, + onTap: onTap, + leading: Icon(SubjectIcon.resolveVariant(subject: subject, context: context), size: 32.0), + title: Text( + subject.renamedTo ?? subject.name.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15.0, fontStyle: subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: AbsenceDisplay(excused, unexcused, pending), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8.0), + if (percentage >= 0) + Stack( + alignment: Alignment.centerRight, + children: [ + const Opacity(child: Text("100%", style: TextStyle(fontFamily: "monospace")), opacity: 0), + Text( + percentage.round().toString() + "%", + style: TextStyle( + // fontFamily: "monospace", + color: getColorByPercentage(percentage, context: context), + fontWeight: FontWeight.w700, + fontSize: 24.0, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +Color getColorByPercentage(double percentage, {required BuildContext context}) { + Color color = AppColors.of(context).text; + + percentage = percentage.round().toDouble(); + + if (percentage > 35) { + color = AppColors.of(context).red; + } else if (percentage > 25) { + color = AppColors.of(context).orange; + } else if (percentage > 15) { + color = AppColors.of(context).yellow; + } + + return color.withOpacity(.8); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.dart new file mode 100755 index 0000000..921a88f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.dart @@ -0,0 +1,118 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence_group/absence_group_container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'absence_tile.i18n.dart'; + +class AbsenceTile extends StatelessWidget { + const AbsenceTile(this.absence, {Key? key, this.onTap, this.elevation = 0.0, this.padding}) : super(key: key); + + final Absence absence; + final void Function()? onTap; + final double elevation; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + Color color = justificationColor(absence.state, context: context); + bool group = AbsenceGroupContainer.of(context) != null; + + return Container( + decoration: BoxDecoration( + boxShadow: [ + if (elevation > 0) + BoxShadow( + offset: Offset(0, 21 * elevation), + blurRadius: 23.0 * elevation, + color: Theme.of(context).shadowColor, + ) + ], + borderRadius: BorderRadius.circular(14.0), + ), + child: Material( + type: MaterialType.transparency, + child: Padding( + padding: padding ?? (group ? EdgeInsets.zero : const EdgeInsets.symmetric(horizontal: 8.0)), + child: ListTile( + onTap: onTap, + visualDensity: VisualDensity.compact, + dense: group, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(!group ? 14.0 : 12.0)), + leading: Container( + width: 44.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: !group ? color.withOpacity(.25) : null, + ), + child: Center(child: Icon(justificationIcon(absence.state), color: color)), + ), + title: !group + ? Text.rich(TextSpan( + text: "${absence.delay == 0 ? "" : absence.delay}", + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15.5), + children: [ + TextSpan( + text: absence.delay == 0 + ? justificationName(absence.state).fill(["absence".i18n]).capital() + : 'minute'.plural(absence.delay) + justificationName(absence.state).fill(["delay".i18n]), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + )) + : Text( + (absence.lessonIndex != null ? "${absence.lessonIndex}. " : "") + (absence.subject.renamedTo ?? absence.subject.name.capital()), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14.0, fontStyle: absence.subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: !group + ? Text( + absence.subject.renamedTo ?? absence.subject.name.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + // DateFormat("MM. dd. (EEEEE)", I18n.of(context).locale.toString()).format(absence.date), + style: TextStyle(fontWeight: FontWeight.w500, fontStyle: absence.subject.isRenamed ? FontStyle.italic : null), + ) + : null, + ), + ), + ), + ); + } + + static String justificationName(Justification state) { + switch (state) { + case Justification.excused: + return "excused".i18n; + case Justification.pending: + return "pending".i18n; + case Justification.unexcused: + return "unexcused".i18n; + } + } + + static Color justificationColor(Justification state, {required BuildContext context}) { + switch (state) { + case Justification.excused: + return AppColors.of(context).green; + case Justification.pending: + return AppColors.of(context).orange; + case Justification.unexcused: + return AppColors.of(context).red; + } + } + + static IconData justificationIcon(Justification state) { + switch (state) { + case Justification.excused: + return FeatherIcons.check; + case Justification.pending: + return FeatherIcons.slash; + case Justification.unexcused: + return FeatherIcons.x; + } + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.i18n.dart new file mode 100755 index 0000000..515cda1 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_tile.i18n.dart @@ -0,0 +1,36 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "excused": "excused %s", + "pending": "%s to be excused", + "unexcused": "unexcused %s", + "absence": "absence", + "delay": "delay", + "minute": " minutes of ".one(" minute of "), + }, + "hu_hu": { + "excused": "igazolt %s", + "pending": "igazolandó %s", + "unexcused": "igazolatlan %s", + "absence": "hiányzás", + "delay": "késés", + "minute": " perc ", + }, + "de_de": { + "excused": "anerkannt %s", + "pending": "%s zu anerkennen", + "unexcused": "unanerkannt %s", + "absence": "Abwesenheit", + "delay": "Verspätung", + "minute": " Minuten ".one(" Minute "), + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.dart new file mode 100755 index 0000000..df0e3a2 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.dart @@ -0,0 +1,128 @@ +// ignore_for_file: empty_catches + +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_action_button.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_tile.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo/utils/reverse_search.dart'; +import 'absence_view.i18n.dart'; + +class AbsenceView extends StatelessWidget { + const AbsenceView(this.absence, {Key? key, this.outsideContext, this.viewable = false}) : super(key: key); + + final Absence absence; + final BuildContext? outsideContext; + final bool viewable; + + static show(Absence absence, {required BuildContext context}) { + showBottomCard(context: context, child: AbsenceView(absence, outsideContext: context)); + } + + @override + Widget build(BuildContext context) { + Color color = AbsenceTile.justificationColor(absence.state, context: context); + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 16.0, right: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + leading: Container( + width: 44.0, + height: 44.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withOpacity(.25), + ), + child: Center( + child: Icon( + AbsenceTile.justificationIcon(absence.state), + color: color, + ), + ), + ), + title: Text( + absence.subject.renamedTo ?? absence.subject.name.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w700, fontStyle: absence.subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: Text( + absence.teacher, + // DateFormat("MM. dd. (EEEEE)", I18n.of(context).locale.toString()).format(absence.date), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + absence.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Absence Details + if (absence.delay > 0) + Detail( + title: "delay".i18n, + description: absence.delay.toString() + " " + "minutes".i18n.plural(absence.delay), + ), + if (absence.lessonIndex != null) + Detail( + title: "Lesson".i18n, + description: "${absence.lessonIndex}. (${absence.lessonStart.format(context, timeOnly: true)}" + " - " + "${absence.lessonEnd.format(context, timeOnly: true)})", + ), + if (absence.justification != null) + Detail( + title: "Excuse".i18n, + description: absence.justification?.description ?? "", + ), + if (absence.mode != null) Detail(title: "Mode".i18n, description: absence.mode?.description ?? ""), + Detail(title: "Submit date".i18n, description: absence.submitDate.format(context)), + + // Show in timetable + if (!viewable) + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 6.0, top: 12.0), + child: PanelActionButton( + leading: const Icon(FeatherIcons.calendar), + title: Text( + "show in timetable".i18n, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + Navigator.of(context).pop(); + + if (outsideContext != null) { + ReverseSearch.getLessonByAbsence(absence, context).then((lesson) { + if (lesson != null) { + TimetablePage.jump(outsideContext!, lesson: lesson); + } else { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + content: Text("Cannot find lesson".i18n, style: const TextStyle(color: Colors.white)), + backgroundColor: AppColors.of(context).red, + context: context, + )); + } + }); + } + }, + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.i18n.dart new file mode 100755 index 0000000..3af7d33 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_view.i18n.dart @@ -0,0 +1,39 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Lesson": "Lesson", + "Excuse": "Excuse", + "Mode": "Mode", + "Submit date": "Submit Date", + "show in timetable": "Show in timetable", + "minutes": "minutes".one("minute"), + "delay": "Delay", + }, + "hu_hu": { + "Lesson": "Óra", + "Excuse": "Igazolás", + "Mode": "Típus", + "Submit date": "Rögzítés dátuma", + "show in timetable": "Megtekintés az órarendben", + "minutes": "perc", + "delay": "Késés", + }, + "de_de": { + "Lesson": "Stunde", + "Excuse": "Anerkannt", + "Mode": "Typ", + "Submit date": "Datum einreichen", + "show in timetable": "im Stundenplan anzeigen", + "minutes": "Minuten".one("Minute"), + "delay": "Verspätung", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_viewable.dart new file mode 100755 index 0000000..9d85cec --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence/absence_viewable.dart @@ -0,0 +1,68 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_mobile_ui/common/viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_view.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence_group/absence_group_container.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/card_handle.dart'; +import 'package:filcnaplo_mobile_ui/pages/absences/absence_subject_view_container.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/reverse_search.dart'; + +import 'absence_view.i18n.dart'; + +class AbsenceViewable extends StatelessWidget { + const AbsenceViewable(this.absence, {Key? key, this.padding}) : super(key: key); + + final Absence absence; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final subject = AbsenceSubjectViewContainer.of(context) != null; + final group = AbsenceGroupContainer.of(context) != null; + final tile = AbsenceTile(absence, padding: padding); + + return Viewable( + tile: group ? AbsenceGroupContainer(child: tile) : tile, + view: CardHandle(child: AbsenceView(absence, viewable: true)), + actions: [ + PanelButton( + background: true, + title: Text( + "show in timetable".i18n, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop(); + + if (subject) { + Future.delayed(const Duration(milliseconds: 250)).then((_) { + Navigator.of(context, rootNavigator: true).pop(absence); + }); + } else { + Future.delayed(const Duration(milliseconds: 250)).then((_) { + ReverseSearch.getLessonByAbsence(absence, context).then((lesson) { + if (lesson != null) { + TimetablePage.jump(context, lesson: lesson); + } else { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + content: Text("Cannot find lesson".i18n, style: const TextStyle(color: Colors.white)), + backgroundColor: AppColors.of(context).red, + context: context, + )); + } + }); + }); + } + }, + ), + ], + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_container.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_container.dart new file mode 100755 index 0000000..6b42750 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_container.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class AbsenceGroupContainer extends InheritedWidget { + const AbsenceGroupContainer({Key? key, required Widget child}) : super(key: key, child: child); + + static AbsenceGroupContainer? of(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(AbsenceGroupContainer oldWidget) => false; +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.dart new file mode 100755 index 0000000..76d5fb7 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.dart @@ -0,0 +1,80 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence_group/absence_group_container.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_tile.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter/material.dart'; +import 'absence_group_tile.i18n.dart'; + +class AbsenceGroupTile extends StatelessWidget { + const AbsenceGroupTile(this.absences, {Key? key, this.showDate = false, this.padding}) : super(key: key); + + final List absences; + final bool showDate; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + Justification state = getState(absences.map((e) => e.absence.state).toList()); + Color color = AbsenceTile.justificationColor(state, context: context); + + absences.sort((a, b) => a.absence.lessonIndex?.compareTo(b.absence.lessonIndex ?? 0) ?? -1); + + return ClipRRect( + borderRadius: BorderRadius.circular(14.0), + child: Material( + type: MaterialType.transparency, + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: AbsenceGroupContainer( + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 8.0), + backgroundColor: Colors.transparent, + leading: Container( + width: 44.0, + height: 44.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withOpacity(.25), + ), + child: Center(child: Icon(AbsenceTile.justificationIcon(state), color: color)), + ), + title: Text.rich(TextSpan( + text: "${absences.where((a) => a.absence.state == state).length} ", + style: TextStyle(fontWeight: FontWeight.w700, color: AppColors.of(context).text), + children: [ + TextSpan( + text: AbsenceTile.justificationName(state).fill(["absence".i18n]), + style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.of(context).text), + ), + ], + )), + subtitle: showDate + ? Text( + absences.first.absence.date.format(context, weekday: true), + style: TextStyle(fontWeight: FontWeight.w500, color: AppColors.of(context).text.withOpacity(0.8)), + ) + : null, + children: absences, + ), + ), + ), + ), + ); + } + + static Justification getState(List states) { + Justification state; + + if (states.any((element) => element == Justification.unexcused)) { + state = Justification.unexcused; + } else if (states.any((element) => element == Justification.pending)) { + state = Justification.pending; + } else { + state = Justification.excused; + } + + return state; + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.i18n.dart new file mode 100755 index 0000000..0b121f6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/absence_group/absence_group_tile.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "absence": "absences", + }, + "hu_hu": { + "absence": "hiányzás", + }, + "de_de": { + "absence": "Fehlen", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/card_handle.dart b/filcnaplo_mobile_ui/lib/common/widgets/card_handle.dart new file mode 100755 index 0000000..f2fa19c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/card_handle.dart @@ -0,0 +1,27 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class CardHandle extends StatelessWidget { + const CardHandle({Key? key, this.child}) : super(key: key); + + final Widget? child; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42.0, + height: 4.0, + margin: const EdgeInsets.only(top: 12.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(45.0), + color: AppColors.of(context).text.withOpacity(0.10), + ), + ), + if (child != null) child!, + ], + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.dart b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.dart new file mode 100755 index 0000000..17d816b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.dart @@ -0,0 +1,108 @@ +import 'package:filcnaplo/helpers/average_helper.dart'; +import 'package:i18n_extension/i18n_widget.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/cretification/certification_view.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'certification_card.i18n.dart'; + +class CertificationCard extends StatelessWidget { + const CertificationCard(this.grades, {Key? key, required this.gradeType, this.padding}) : super(key: key); + + final List grades; + final GradeType gradeType; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + String title = getGradeTypeTitle(gradeType); + double average = AverageHelper.averageEvals(grades, finalAvg: true); + String averageText = average.toStringAsFixed(1); + if (I18n.of(context).locale.languageCode != "en") averageText = averageText.replaceAll(".", ","); + Color color = gradeColor(context: context, value: average); + Color textColor; + + if (color.computeLuminance() >= .5) { + textColor = Colors.black; + } else { + textColor = Colors.white; + } + + return Padding( + padding: padding ?? const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + gradient: LinearGradient( + colors: [color, color.withOpacity(.75)], + ), + ), + child: Material( + type: MaterialType.transparency, + borderRadius: BorderRadius.circular(12.0), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + leading: Text( + averageText, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + fontSize: 24.0, + ), + ), + title: Text.rich( + TextSpan( + text: title, + children: [ + TextSpan( + text: " • ${grades.length}", + style: TextStyle( + color: textColor.withOpacity(.75), + fontWeight: FontWeight.w600, + fontSize: 16.0, + ), + ), + ], + ), + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w700, + fontSize: 18.0, + ), + ), + trailing: Icon(FeatherIcons.arrowRight, color: textColor), + onTap: () => CertificationView.show(grades, context: context, gradeType: gradeType), + ), + ), + ), + ); + } +} + +String getGradeTypeTitle(GradeType gradeType) { + String title; + + switch (gradeType) { + case GradeType.halfYear: + title = "mid".i18n; + break; + case GradeType.firstQ: + title = "1q".i18n; + break; + case GradeType.secondQ: + title = "2q".i18n; + break; + case GradeType.thirdQ: + title = "3q".i18n; + break; + case GradeType.fourthQ: + title = "4q".i18n; + break; + default: + title = "final".i18n; + } + + return title; +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.i18n.dart new file mode 100755 index 0000000..8946d62 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_card.i18n.dart @@ -0,0 +1,36 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "final": "Final grades", + "mid": "Midterm grades", + "1q": "1. Quarter grades", + "2q": "2. Quarter grades", + "3q": "3. Quarter grades", + "4q": "4. Quarter grades", + }, + "hu_hu": { + "final": "Év végi jegyek", + "mid": "Félévi jegyek", + "1q": "1. Negyedéves jegyek", + "2q": "2. Negyedéves jegyek", + "3q": "3. Negyedéves jegyek", + "4q": "4. Negyedéves jegyek", + }, + "de_de": { + "final": "Zeugnis Noten", + "mid": "Halbjährlich Noten", + "1q": "1. Quartal Noten", + "2q": "2. Quartal Noten", + "3q": "3. Quartal Noten", + "4q": "4. Quartal Noten", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.dart new file mode 100755 index 0000000..5d0d272 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.dart @@ -0,0 +1,87 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/subject_grades_container.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'certification_tile.i18n.dart'; + +class CertificationTile extends StatelessWidget { + const CertificationTile(this.grade, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Function()? onTap; + final Grade grade; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + bool isSubjectView = SubjectGradesContainer.of(context) != null; + String certificationName; + + switch (grade.type) { + case GradeType.endYear: + certificationName = "final".i18n; + break; + case GradeType.halfYear: + certificationName = "mid".i18n; + break; + case GradeType.firstQ: + certificationName = "1q".i18n; + break; + case GradeType.secondQ: + certificationName = "2q".i18n; + break; + case GradeType.thirdQ: + certificationName = "3q".i18n; + break; + case GradeType.fourthQ: + certificationName = "4q".i18n; + break; + case GradeType.levelExam: + certificationName = "equivalency".i18n; + break; + case GradeType.unknown: + default: + certificationName = "unknown".i18n; + } + + return Material( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(8.0), + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: + isSubjectView ? const EdgeInsets.only(left: 12.0, right: 12.0, top: 2.0, bottom: 8.0) : const EdgeInsets.only(left: 8.0, right: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + onTap: onTap, + leading: isSubjectView + ? GradeValueWidget( + grade.value, + complemented: grade.description == 'Dicséret', + ) + : Padding( + padding: const EdgeInsets.only(left: 2.0), + child: Icon(SubjectIcon.resolveVariant(subject: grade.subject, context: context), + size: 28.0, color: AppColors.of(context).text.withOpacity(.75)), + ), + minLeadingWidth: isSubjectView ? 32.0 : 42.0, + trailing: isSubjectView + ? const Icon(FeatherIcons.award) + : GradeValueWidget( + grade.value, + complemented: grade.description == 'Dicséret', + ), + title: Text(isSubjectView ? certificationName : grade.subject.renamedTo ?? grade.subject.name.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18.0, fontStyle: grade.subject.isRenamed ? FontStyle.italic : null)), + subtitle: Text(grade.value.valueName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16.0)), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.i18n.dart new file mode 100755 index 0000000..37f3f33 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_tile.i18n.dart @@ -0,0 +1,45 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "final": "Final", + "mid": "Mid year", + "1q": "1. Quarter", + "2q": "2. Quarter", + "3q": "3. Quarter", + "4q": "4. Quarter", + "equivalency": "Equivalency test", + "unknown": "Unknown", + "classavg": "Class Average", + }, + "hu_hu": { + "final": "Év vége", + "mid": "Félév", + "1q": "1. Negyedév", + "2q": "2. Negyedév", + "3q": "3. Negyedév", + "4q": "4. Negyedév", + "equivalency": "Osztályozó", + "unknown": "Ismeretlen", + "classavg": "Osztályátlag", + }, + "de_de": { + "final": "Zeugnis", + "mid": "Halbjährlich", + "1q": "1. Quartal", + "2q": "2. Quartal", + "3q": "3. Quartal", + "4q": "4. Quartal", + "equivalency": "Zulassungsprüfung", + "unknown": "Unbekannt", + "classavg": "Klassendurchschnitt", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_view.dart new file mode 100755 index 0000000..107815f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/cretification/certification_view.dart @@ -0,0 +1,43 @@ +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/cretification/certification_card.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/cretification/certification_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/hero_scrollview.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class CertificationView extends StatelessWidget { + const CertificationView(this.grades, {Key? key, required this.gradeType}) : super(key: key); + + final List grades; + final GradeType gradeType; + + static show(List grades, {required BuildContext context, required GradeType gradeType}) => + Navigator.of(context, rootNavigator: true).push(CupertinoPageRoute(builder: (context) => CertificationView(grades, gradeType: gradeType))); + + @override + Widget build(BuildContext context) { + grades.sort((a, b) => a.subject.name.compareTo(b.subject.name)); + List tiles = grades.map((e) => CertificationTile(e)).toList(); + return Scaffold( + body: HeroScrollView( + title: getGradeTypeTitle(gradeType), + icon: FeatherIcons.award, + iconSize: 50, + child: ListView( + children: [ + SafeArea( + child: Panel( + child: Column( + children: tiles, + ), + ), + ) + ], + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + physics: const BouncingScrollPhysics(), + ))); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/custom_switch.dart b/filcnaplo_mobile_ui/lib/common/widgets/custom_switch.dart new file mode 100755 index 0000000..0c845f3 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/custom_switch.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class CustomSwitch extends StatelessWidget { + final ValueChanged onChanged; + final bool value; + + const CustomSwitch({ + Key? key, + required this.onChanged, + required this.value, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: SizedBox( + height: 25, + width: 50, + child: Stack( + children: [ + AnimatedContainer( + height: 25, + width: 50, + curve: Curves.ease, + duration: const Duration(milliseconds: 400), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(25.0), + ), + color: value ? Theme.of(context).colorScheme.secondary : Theme.of(context).highlightColor, + ), + ), + AnimatedAlign( + curve: Curves.ease, + duration: const Duration(milliseconds: 400), + alignment: !value ? Alignment.centerLeft : Alignment.centerRight, + child: Container( + height: 20, + width: 20, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12.withOpacity(0.1), + spreadRadius: 0.5, + blurRadius: 1, + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/event/event_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/event/event_tile.dart new file mode 100755 index 0000000..0d93314 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/event/event_tile.dart @@ -0,0 +1,46 @@ +import 'package:filcnaplo_kreta_api/models/event.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:flutter/material.dart'; + +class EventTile extends StatelessWidget { + const EventTile(this.event, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Event event; + final void Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(14.0), + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + leading: const ProfileImage( + name: "!", + radius: 22.0, + ), + title: Text( + event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + event.content.escapeHtml().replaceAll('\n', ' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + minLeadingWidth: 0, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/event/event_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/event/event_view.dart new file mode 100755 index 0000000..dd0cc7e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/event/event_view.dart @@ -0,0 +1,57 @@ +import 'package:filcnaplo_kreta_api/models/event.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/common/sliding_bottom_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; + +class EventView extends StatelessWidget { + const EventView(this.event, {Key? key}) : super(key: key); + + final Event event; + + static void show(Event event, {required BuildContext context}) => showSlidingBottomSheet(context: context, child: EventView(event)); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + ListTile( + title: Text( + event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + trailing: Text( + event.start.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Details + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SelectableLinkify( + text: event.content.escapeHtml(), + options: const LinkifyOptions(looseUrl: true, removeWww: true), + onOpen: (link) { + launch(link.url, + customTabsOption: CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )); + }, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/event/event_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/event/event_viewable.dart new file mode 100755 index 0000000..453cd16 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/event/event_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo_kreta_api/models/event.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/event/event_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/event/event_view.dart'; +import 'package:flutter/material.dart'; + +class EventViewable extends StatelessWidget { + const EventViewable(this.event, {Key? key}) : super(key: key); + + final Event event; + + @override + Widget build(BuildContext context) { + return EventTile( + event, + onTap: () => EventView.show(event, context: context), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_tile.dart new file mode 100755 index 0000000..949df10 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_tile.dart @@ -0,0 +1,58 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/exam.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class ExamTile extends StatelessWidget { + const ExamTile(this.exam, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Exam exam; + final void Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + leading: SizedBox( + width: 44, + height: 44, + child: Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Icon( + SubjectIcon.resolveVariant(subjectName: exam.subjectName, context: context), + size: 28.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + )), + title: Text( + exam.description != "" ? exam.description : (exam.mode?.description ?? "Számonkérés"), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + exam.subjectName.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Icon( + FeatherIcons.edit, + color: AppColors.of(context).text.withOpacity(.75), + ), + minLeadingWidth: 0, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.dart new file mode 100755 index 0000000..0d47ba3 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.dart @@ -0,0 +1,61 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/exam.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:flutter/material.dart'; +import 'exam_view.i18n.dart'; + +class ExamView extends StatelessWidget { + const ExamView(this.exam, {Key? key}) : super(key: key); + + final Exam exam; + + static show(Exam exam, {required BuildContext context}) => showBottomCard(context: context, child: ExamView(exam)); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + ListTile( + leading: Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + SubjectIcon.resolveVariant(subjectName: exam.subjectName, context: context), + size: 36.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + ), + title: Text( + exam.subjectName.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + exam.teacher, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + exam.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Details + if (exam.writeDate.year != 0) Detail(title: "date".i18n, description: exam.writeDate.format(context)), + if (exam.description != "") Detail(title: "description".i18n, description: exam.description), + if (exam.mode != null) Detail(title: "mode".i18n, description: exam.mode!.description), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.i18n.dart new file mode 100755 index 0000000..b038ec2 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_view.i18n.dart @@ -0,0 +1,27 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "date": "Date", + "description": "Description", + "mode": "Type", + }, + "hu_hu": { + "date": "Írás ideje", + "description": "Leírás", + "mode": "Típus", + }, + "de_de": { + "date": "Prüfungszeit", + "description": "Bezeichnung", + "mode": "Typ", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_viewable.dart new file mode 100755 index 0000000..4ee3565 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/exam/exam_viewable.dart @@ -0,0 +1,20 @@ +import 'package:filcnaplo_kreta_api/models/exam.dart'; +import 'package:filcnaplo_mobile_ui/common/viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/card_handle.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/exam/exam_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/exam/exam_view.dart'; +import 'package:flutter/material.dart'; + +class ExamViewable extends StatelessWidget { + const ExamViewable(this.exam, {Key? key}) : super(key: key); + + final Exam exam; + + @override + Widget build(BuildContext context) { + return Viewable( + tile: ExamTile(exam), + view: CardHandle(child: ExamView(exam)), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_subject_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_subject_tile.dart new file mode 100755 index 0000000..8b992c6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_subject_tile.dart @@ -0,0 +1,70 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:flutter/material.dart'; + +class GradeSubjectTile extends StatelessWidget { + const GradeSubjectTile(this.subject, {Key? key, this.average = 0.0, this.groupAverage = 0.0, this.onTap, this.averageBefore = 0.0}) + : super(key: key); + + final Subject subject; + final void Function()? onTap; + final double average; + final double groupAverage; + final double averageBefore; + + @override + Widget build(BuildContext context) { + Color textColor = AppColors.of(context).text; + + // Failing indicator + if (average < 2.0 && average >= 1.0) { + textColor = AppColors.of(context).red; + } + + final String changeIcon = average < averageBefore ? "▼" : "▲"; + final Color changeColor = average < averageBefore ? Colors.redAccent : Colors.lightGreenAccent.shade700; + + return Material( + type: MaterialType.transparency, + child: ListTile( + minLeadingWidth: 32.0, + dense: true, + contentPadding: const EdgeInsets.only(left: 8.0, right: 6.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + visualDensity: VisualDensity.compact, + onTap: onTap, + leading: Icon(SubjectIcon.resolveVariant(subject: subject, context: context), color: textColor.withOpacity(.75)), + title: Text( + subject.renamedTo ?? subject.name.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14.0, color: textColor, fontStyle: subject.isRenamed ? FontStyle.italic : null), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (groupAverage != 0 && averageBefore == 0.0) AverageDisplay(average: groupAverage, border: true), + const SizedBox(width: 6.0), + if (averageBefore != 0.0 && averageBefore != average) ...[ + AverageDisplay(average: averageBefore), + Padding( + padding: const EdgeInsets.only(left: 6.0, right: 6.0, bottom: 3.5), + child: Text( + changeIcon, + style: TextStyle( + color: changeColor, + fontSize: 20.0, + ), + ), + ) + ], + AverageDisplay(average: average) + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.dart new file mode 100755 index 0000000..3304cbd --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.dart @@ -0,0 +1,60 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'grade_view.i18n.dart'; + +class GradeView extends StatelessWidget { + const GradeView(this.grade, {Key? key}) : super(key: key); + + static show(Grade grade, {required BuildContext context}) => showBottomCard(context: context, child: GradeView(grade)); + + final Grade grade; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: GradeValueWidget(grade.value, fill: true), + title: Text( + grade.subject.renamedTo ?? grade.subject.name.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600, fontStyle: grade.subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: Text( + !Provider.of(context, listen: false).presentationMode ? grade.teacher : "Tanár", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + grade.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Grade Details + Detail( + title: "value".i18n, + description: "${grade.value.valueName} " + percentText(), + ), + if (grade.description != "") Detail(title: "description".i18n, description: grade.description), + if (grade.mode.description != "") Detail(title: "mode".i18n, description: grade.mode.description), + if (grade.writeDate.year != 0) Detail(title: "date".i18n, description: grade.writeDate.format(context)), + ], + ), + ); + } + + String percentText() => grade.value.weight != 100 && grade.value.weight > 0 ? "${grade.value.weight}%" : ""; +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.i18n.dart new file mode 100755 index 0000000..955073b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_view.i18n.dart @@ -0,0 +1,30 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "value": "Value", + "date": "Date", + "description": "Description", + "mode": "Type", + }, + "hu_hu": { + "value": "Érték", + "date": "Írás ideje", + "description": "Leírás", + "mode": "Típus", + }, + "de_de": { + "value": "Notenwert", + "date": "Prüfungszeit", + "description": "Bezeichnung", + "mode": "Typ", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_viewable.dart new file mode 100755 index 0000000..627cc82 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/grade_viewable.dart @@ -0,0 +1,25 @@ +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/card_handle.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/grade/grade_view.dart'; +import 'package:filcnaplo_mobile_ui/common/viewable.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/subject_grades_container.dart'; +import 'package:flutter/material.dart'; + +class GradeViewable extends StatelessWidget { + const GradeViewable(this.grade, {Key? key, this.padding}) : super(key: key); + + final Grade grade; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final subject = SubjectGradesContainer.of(context) != null; + final tile = GradeTile(grade, padding: subject ? EdgeInsets.zero : padding); + + return Viewable( + tile: subject ? SubjectGradesContainer(child: tile) : tile, + view: CardHandle(child: GradeView(grade)), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.dart new file mode 100755 index 0000000..319a219 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.dart @@ -0,0 +1,158 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/grade/surprise_grade.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:rive/rive.dart'; + +import 'new_grades.i18n.dart'; + +class NewGradesSurprise extends StatelessWidget { + const NewGradesSurprise(this.grades, {Key? key, this.censored = false}) : super(key: key); + + final List grades; + final bool censored; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).colorScheme.secondary, + width: 3.0, + ), + borderRadius: BorderRadius.circular(14.0), + ), + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: () => openingFun(context), + minLeadingWidth: 54, + leading: SizedBox( + width: 44, + height: 44, + child: Center( + child: Container( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + color: Theme.of(context).colorScheme.secondary.withOpacity(.5), + blurRadius: 18.0, + ) + ]), + child: const RiveAnimation.asset("assets/animations/backpack-2.riv"), + ), + ), + ), + title: censored + ? Wrap( + children: [ + Container( + width: 85, + height: 15, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.85), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ) + : Text( + "new_grades".i18n, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: censored + ? Wrap( + children: [ + Container( + width: 125, + height: 10, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.45), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ) + : Text( + "tap_to_open".i18n, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: censored + ? Wrap( + children: [ + Container( + width: 25, + height: 25, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.45), + borderRadius: BorderRadius.circular(25.0), + ), + ), + ], + ) + : Text.rich( + TextSpan(children: [ + TextSpan( + text: "${grades.length}", + style: TextStyle( + shadows: [ + Shadow( + color: AppColors.of(context).text.withOpacity(.2), + offset: const Offset(2, 2), + ) + ], + )), + TextSpan( + text: "x", + style: TextStyle( + fontSize: 20.0, + color: AppColors.of(context).text.withOpacity(.5), + fontWeight: FontWeight.w800, + ), + ) + ]), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 28.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + ), + ), + ), + ); + } + + void openingFun(BuildContext context) { + final settings = Provider.of(context, listen: false); + if (!settings.gradeOpeningFun) return; + + final gradeProvider = Provider.of(context, listen: false); + + final newGrades = gradeProvider.grades.where((element) => element.date.isAfter(gradeProvider.lastSeenDate)).toList(); + newGrades.sort((a, b) => a.date.compareTo(b.date)); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + for (final grade in newGrades) { + await showDialog( + context: context, + builder: (context) => SurpriseGrade(grade), + useRootNavigator: true, + barrierDismissible: false, + barrierColor: Colors.transparent, + useSafeArea: false, + ); + await Future.delayed(const Duration(milliseconds: 300)); + } + await gradeProvider.seenAll(); + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.i18n.dart new file mode 100755 index 0000000..5215d8e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/new_grades.i18n.dart @@ -0,0 +1,42 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "common": "Common", + "uncommon": "Uncommon", + "rare": "Rare", + "epic": "Epic", + "legendary": "Legendary", + "new_grades": "New grades", + "tap_to_open": "Tap to open now!", + "open_subtitle": "Tap to open...", + }, + "hu_hu": { + "common": "Gyakori", + "uncommon": "Nem gyakori", + "rare": "Ritka", + "epic": "Epikus", + "legendary": "Legendás", + "new_grades": "Új jegyek", + "tap_to_open": "Nyisd ki őket!", + "open_subtitle": "Nyomd meg a kinyitáshoz...", + }, + "de_de": { + "common": "Gemeinsam", + "uncommon": "Gelegentlich", + "rare": "Selten", + "epic": "Episch", + "legendary": "Legendär", + "new_grades": "Neue Noten", + "tap_to_open": "Tippen, um jetzt zu öffnen!", + "open_subtitle": "Antippen zum Öffnen...", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/grade/surprise_grade.dart b/filcnaplo_mobile_ui/lib/common/widgets/grade/surprise_grade.dart new file mode 100755 index 0000000..598287e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/grade/surprise_grade.dart @@ -0,0 +1,389 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:animated_background/animated_background.dart' as bg; +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/pages/home/particle.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:rive/rive.dart' as rive; + +import 'new_grades.i18n.dart'; + +class SurpriseGrade extends StatefulWidget { + const SurpriseGrade(this.grade, {Key? key}) : super(key: key); + + final Grade grade; + + @override + State createState() => _SurpriseGradeState(); +} + +class _SurpriseGradeState extends State with TickerProviderStateMixin { + late AnimationController _revealAnimFade; + late AnimationController _revealAnimScale; + late AnimationController _revealAnimGrade; + late AnimationController _revealAnimParticle; + late rive.RiveAnimationController _controller; + + @override + void initState() { + super.initState(); + _revealAnimFade = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); + _revealAnimScale = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + _revealAnimGrade = AnimationController(vsync: this, duration: const Duration(seconds: 1)); + _revealAnimParticle = AnimationController(vsync: this, duration: const Duration(seconds: 2)); + _revealAnimScale.animateTo(0.7, duration: Duration.zero); + _controller = rive.SimpleAnimation('Timeline 1', autoplay: false); + WidgetsBinding.instance.addPostFrameCallback((_) { + _revealAnimFade.animateTo(1.0, curve: Curves.easeInOut); + Future.delayed(const Duration(milliseconds: 200), () { + _revealAnimScale.animateTo(1.0, curve: Curves.easeInOut).then((_) { + setState(() => subtitle = true); + }); + }); + }); + + seed = Random().nextInt(100000000); + } + + @override + void dispose() { + _revealAnimFade.dispose(); + _revealAnimScale.dispose(); + _revealAnimGrade.dispose(); + _revealAnimParticle.dispose(); + _controller.dispose(); + super.dispose(); + } + + bool hold = false; + bool subtitle = false; + late int seed; + + void reveal() async { + if (!subtitle) { + _revealAnimParticle.animateBack(0.0, curve: Curves.fastLinearToSlowEaseIn, duration: const Duration(milliseconds: 300)); + await Future.delayed(const Duration(milliseconds: 50)); + _revealAnimGrade.animateBack(0.0, curve: Curves.fastLinearToSlowEaseIn); + await Future.delayed(const Duration(milliseconds: 50)); + _revealAnimFade.animateBack(0.0, curve: Curves.easeInOut); + _revealAnimScale.animateBack(0.0, curve: Curves.easeInOut); + if (mounted) Navigator.of(context).pop(); + return; + } + subtitle = false; + setState(() => hold = false); + _controller.isActive = true; + await Future.delayed(const Duration(seconds: 2)); + if (mounted) _revealAnimGrade.animateTo(1.0, curve: Curves.fastLinearToSlowEaseIn); + await Future.delayed(const Duration(milliseconds: 700)); + if (mounted) await _revealAnimParticle.animateTo(1.0, curve: Curves.fastLinearToSlowEaseIn); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _revealAnimFade, + builder: (context, child) { + return FadeTransition( + opacity: _revealAnimFade, + child: Material( + color: Colors.black.withOpacity(.75), + child: Container( + color: Theme.of(context).colorScheme.secondary.withOpacity(.05), + child: Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + colors: [Colors.transparent, Colors.black], + radius: 1.5, + stops: [0.2, 1.0], + ), + ), + child: bg.AnimatedBackground( + vsync: this, + behaviour: bg.RandomParticleBehaviour( + options: bg.ParticleOptions( + baseColor: Theme.of(context).colorScheme.secondary, + spawnMinSpeed: 5.0, + spawnMaxSpeed: 10.0, + minOpacity: .05, + maxOpacity: .08, + spawnMinRadius: 30.0, + spawnMaxRadius: 50.0, + particleCount: 20, + ), + ), + child: ScaleTransition( + scale: _revealAnimScale, + child: child, + ), + ), + ), + ), + ), + ); + }, + child: AnimatedBuilder( + animation: _revealAnimGrade, + builder: (context, child) { + return Stack( + alignment: Alignment.center, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SlideTransition( + position: _revealAnimGrade.drive(Tween(begin: Offset.zero, end: const Offset(0, 0.7))), + child: AnimatedScale( + scale: hold ? 1.1 : 1.0, + curve: Curves.easeOutBack, + duration: const Duration(milliseconds: 200), + child: GestureDetector( + onLongPressDown: (_) => setState(() => hold = true), + onLongPressEnd: (_) => reveal(), + onLongPressCancel: reveal, + child: ScaleTransition( + scale: CurvedAnimation(curve: Curves.easeInOut, parent: _revealAnimGrade.drive(Tween(begin: 1.0, end: 0.8))), + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 300, + height: 300, + child: rive.RiveAnimation.asset( + "assets/animations/backpack-2.riv", + fit: BoxFit.contain, + controllers: [_controller], + antialiasing: false, + ), + ), + SlideTransition( + position: _revealAnimParticle.drive(Tween(begin: const Offset(0, 0.3), end: const Offset(0, 0.8))), + child: FadeTransition( + opacity: _revealAnimParticle, + child: ClipRRect( + borderRadius: BorderRadius.circular(24.0), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 32.0, sigmaY: 32.0), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 20.0), + decoration: BoxDecoration( + color: Colors.white.withOpacity(.3), + borderRadius: BorderRadius.circular(24.0), + border: Border.all(color: Colors.black.withOpacity(.3), width: 1.0), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.grade.description != "") + Text( + widget.grade.description, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 26.0, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.grade.subject.renamedTo ?? widget.grade.subject.name.capital(), + style: TextStyle( + color: Colors.white.withOpacity(.8), + fontWeight: FontWeight.bold, + fontSize: 24.0, + fontStyle: widget.grade.subject.isRenamed ? FontStyle.italic : null), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + "${widget.grade.value.weight}%", + style: TextStyle( + color: Colors.white.withOpacity(.7), + fontWeight: FontWeight.w600, + fontSize: 20.0, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 20.0), + Icon( + SubjectIcon.resolveVariant(subject: widget.grade.subject, context: context), + color: Colors.white, + size: 82.0, + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 42.0), + AnimatedOpacity( + opacity: subtitle ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: Text( + "open_subtitle".i18n, + style: TextStyle( + color: Colors.white.withOpacity(.8), + fontWeight: FontWeight.w600, + fontSize: 24.0, + ), + ), + ), + ], + ), + if (_revealAnimGrade.value > 0) + AnimatedBuilder( + animation: _revealAnimParticle, + builder: (context, child) { + bool shouldPaint = false; + if (_revealAnimParticle.status == AnimationStatus.forward || _revealAnimParticle.status == AnimationStatus.reverse) { + shouldPaint = true; + } + return ScaleTransition( + scale: _revealAnimGrade, + child: FadeTransition( + opacity: _revealAnimGrade, + child: SlideTransition( + position: _revealAnimGrade.drive(Tween(begin: Offset.zero, end: const Offset(0, -0.6))), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SlideTransition( + position: _revealAnimGrade.drive(Tween(begin: Offset.zero, end: const Offset(0, -0.9))), + child: Text( + ["legendary", "epic", "rare", "uncommon", "common"][5 - widget.grade.value.value].i18n, + style: TextStyle( + fontSize: 46.0, + fontWeight: FontWeight.bold, + color: gradeColor(context: context, value: widget.grade.value.value), + shadows: [ + Shadow( + color: gradeColor(context: context, value: widget.grade.value.value).withOpacity(.5), + blurRadius: 24.0, + ), + Shadow( + color: gradeColor(context: context, value: widget.grade.value.value).withOpacity(.3), + offset: const Offset(-3, -3), + ), + ], + ), + ), + ), + const SizedBox(height: 32.0), + ScaleTransition( + scale: CurvedAnimation(curve: Curves.easeInOutBack, parent: _revealAnimParticle.drive(Tween(begin: 0.6, end: 1.0))), + child: CustomPaint( + painter: PimpPainter( + particle: Sprinkles(), + controller: _revealAnimParticle, + seed: seed + 1, + shouldPaint: shouldPaint, + ), + child: CustomPaint( + painter: PimpPainter( + particle: Sprinkles(), + controller: _revealAnimParticle, + seed: seed, + shouldPaint: shouldPaint, + ), + child: RotationTransition( + turns: + CurvedAnimation(curve: Curves.easeInBack, parent: _revealAnimGrade).drive(Tween(begin: 0.95, end: 1.0)), + child: GradeValueWidget( + widget.grade.value, + fill: true, + contrast: true, + shadow: true, + outline: true, + size: 100.0, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + }), + ); + } +} + +class PimpPainter extends CustomPainter { + PimpPainter({required this.particle, required this.seed, required this.controller, required this.shouldPaint}) : super(repaint: controller); + + final Particle particle; + final int seed; + final AnimationController controller; + final bool shouldPaint; + + @override + void paint(Canvas canvas, Size size) { + if (shouldPaint) { + canvas.translate(size.width / 2, size.height / 2); + particle.paint(canvas, size, controller.value, seed); + } + } + + @override + bool shouldRepaint(PimpPainter oldDelegate) => shouldPaint; +} + +Color randomColor(int c) { + c = c % 5; + if (c == 0) return Colors.red.shade300; + if (c == 1) return Colors.green.shade300; + if (c == 2) return Colors.orange.shade300; + if (c == 3) return Colors.blue.shade300; + if (c == 4) return Colors.pink.shade300; + if (c == 5) return Colors.brown.shade300; + return Colors.black; +} + +class Sprinkles extends Particle { + @override + void paint(Canvas canvas, Size size, progress, seed) { + Random random = Random(seed); + int randomMirrorOffset = random.nextInt(8) + 1; + CompositeParticle(children: [ + Firework(), + RectangleMirror.builder( + numberOfParticles: 6, + particleBuilder: (n) { + return AnimatedPositionedParticle( + begin: const Offset(0.0, -10.0), + end: const Offset(0.0, -60.0), + child: FadingRect(width: 5.0, height: 15.0, color: randomColor(n)), + ); + }, + initialDistance: -pi / randomMirrorOffset), + ]).paint(canvas, size, progress, seed); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.dart new file mode 100755 index 0000000..67145a7 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'package:filcnaplo/helpers/attachment_helper.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/image_view.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +import 'package:filcnaplo_kreta_api/models/homework.dart'; +import 'package:flutter/material.dart'; + +import 'homework_attachment_tile.i18n.dart'; + +class HomeworkAttachmentTile extends StatelessWidget { + const HomeworkAttachmentTile(this.attachment, {Key? key}) : super(key: key); + + final HomeworkAttachment attachment; + + Widget buildImage(BuildContext context) { + return FutureBuilder( + future: attachment.download(context), + builder: (context, snapshot) { + return snapshot.hasData + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Material( + child: InkWell( + onTap: () { + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute( + builder: (context) => ImageView(snapshot.data!), + )); + }, + child: Ink.image( + image: FileImage(File(snapshot.data ?? "")), + height: 200.0, + width: double.infinity, + fit: BoxFit.cover, + ), + borderRadius: BorderRadius.circular(12.0), + ), + ), + ), + ) + : Center( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: CircularProgressIndicator(color: Theme.of(context).colorScheme.secondary), + )); + }, + ); + } + + @override + Widget build(BuildContext context) { + if (attachment.isImage) return buildImage(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: InkWell( + borderRadius: BorderRadius.circular(12.0), + onTap: () { + attachment.open(context).then((value) { + if (!value) { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + context: context, + content: Text("Failed to open attachment".i18n), + backgroundColor: AppColors.of(context).red, + duration: const Duration(seconds: 1), + )); + } + }); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [ + const Icon(FeatherIcons.paperclip), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text(attachment.name, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ), + ]), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.i18n.dart new file mode 100755 index 0000000..170969f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_attachment_tile.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Failed to open attachment": "Failed to open attachment", + }, + "hu_hu": { + "Failed to open attachment": "Nem sikerült megnyitni a mellékletet", + }, + "de_de": { + "Failed to open attachment": "Anhang konnte nicht geöffnet werden", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_tile.dart new file mode 100755 index 0000000..e982586 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_tile.dart @@ -0,0 +1,103 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/homework.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class HomeworkTile extends StatelessWidget { + const HomeworkTile(this.homework, {Key? key, this.onTap, this.padding, this.censored = false}) : super(key: key); + + final Homework homework; + final void Function()? onTap; + final EdgeInsetsGeometry? padding; + final bool censored; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(8.0), + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + leading: SizedBox( + width: 44, + height: 44, + child: censored + ? Container( + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.55), + borderRadius: BorderRadius.circular(60.0), + ), + ) + : Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Icon( + SubjectIcon.resolveVariant(subjectName: homework.subjectName, context: context), + size: 28.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + ), + ), + title: censored + ? Wrap( + children: [ + Container( + width: 160, + height: 15, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.85), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ) + : Text( + homework.subjectName.capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: censored + ? Wrap( + children: [ + Container( + width: 100, + height: 10, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.45), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ], + ) + : Text( + homework.content.escapeHtml().replaceAll('\n', ' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: censored + ? Container( + width: 15, + height: 15, + decoration: BoxDecoration( + color: AppColors.of(context).text.withOpacity(.45), + borderRadius: BorderRadius.circular(8.0), + ), + ) + : Icon( + FeatherIcons.home, + color: AppColors.of(context).text.withOpacity(.75), + ), + minLeadingWidth: 0, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.dart new file mode 100755 index 0000000..a059500 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.dart @@ -0,0 +1,88 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo_kreta_api/models/homework.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:filcnaplo_mobile_ui/common/sliding_bottom_sheet.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/homework/homework_attachment_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'homework_view.i18n.dart'; + +class HomeworkView extends StatelessWidget { + const HomeworkView(this.homework, {Key? key}) : super(key: key); + + final Homework homework; + + static show(Homework homework, {required BuildContext context}) { + showSlidingBottomSheet(context: context, child: HomeworkView(homework)); + } + + @override + Widget build(BuildContext context) { + List attachmentTiles = []; + + for (var attachment in homework.attachments) { + attachmentTiles.add(Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: HomeworkAttachmentTile( + attachment, + ), + )); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + ListTile( + leading: Icon( + SubjectIcon.resolveVariant(subjectName: homework.subjectName, context: context), + size: 36.0, + ), + title: Text( + homework.subjectName.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + homework.teacher, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + homework.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Details + if (homework.deadline.year != 0) Detail(title: "deadline".i18n, description: homework.deadline.format(context)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 6.0), + child: SelectableLinkify( + text: homework.content.escapeHtml(), + options: const LinkifyOptions(looseUrl: true, removeWww: true), + onOpen: (link) { + launch(link.url, + customTabsOption: CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )); + }, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + + // Attachments + ...attachmentTiles, + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.i18n.dart new file mode 100755 index 0000000..1e9652b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_view.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "deadline": "Deadline", + }, + "hu_hu": { + "deadline": "Határidő", + }, + "de_de": { + "deadline": "Termin", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_viewable.dart new file mode 100755 index 0000000..18a132c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/homework/homework_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo_kreta_api/models/homework.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/homework/homework_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/homework/homework_view.dart'; +import 'package:flutter/material.dart'; + +class HomeworkViewable extends StatelessWidget { + const HomeworkViewable(this.homework, {Key? key}) : super(key: key); + + final Homework homework; + + @override + Widget build(BuildContext context) { + return HomeworkTile( + homework, + onTap: () => HomeworkView.show(homework, context: context), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.dart new file mode 100755 index 0000000..976c15f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.dart @@ -0,0 +1,75 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'changed_lesson_tile.i18n.dart'; + +class ChangedLessonTile extends StatelessWidget { + const ChangedLessonTile(this.lesson, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Lesson lesson; + final void Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + String lessonIndexTrailing = ""; + + // Only put a trailing . if its a digit + if (RegExp(r'\d').hasMatch(lesson.lessonIndex)) lessonIndexTrailing = "."; + + Color accent = Theme.of(context).colorScheme.secondary; + + if (lesson.substituteTeacher != "") { + accent = AppColors.of(context).yellow; + } + + if (lesson.status?.name == "Elmaradt") { + accent = AppColors.of(context).red; + } + + return Material( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(14.0), + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + leading: SizedBox( + width: 44.0, + height: 44.0, + child: Center( + child: Text( + lesson.lessonIndex + lessonIndexTrailing, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 30.0, + fontWeight: FontWeight.w600, + color: accent, + ), + ), + ), + ), + title: Text( + lesson.substituteTeacher != "" ? "substituted".i18n : "cancelled".i18n, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + lesson.subject.renamedTo ?? lesson.subject.name.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w500, fontStyle: lesson.subject.isRenamed ? FontStyle.italic : null), + ), + trailing: const Icon(FeatherIcons.arrowRight), + minLeadingWidth: 0, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.i18n.dart new file mode 100755 index 0000000..fa50d6b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_tile.i18n.dart @@ -0,0 +1,24 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "cancelled": "Cancelled lesson", + "substituted": "Substituted lesson", + }, + "hu_hu": { + "cancelled": "Elmaradó óra", + "substituted": "Helyettesített óra", + }, + "de_de": { + "cancelled": "Abgesagte Stunde", + "substituted": "Vertretene Stunden", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_viewable.dart new file mode 100755 index 0000000..0709234 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/changed_lesson_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/lesson/changed_lesson_tile.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:flutter/material.dart'; + +class ChangedLessonViewable extends StatelessWidget { + const ChangedLessonViewable(this.lesson, {Key? key}) : super(key: key); + + final Lesson lesson; + + @override + Widget build(BuildContext context) { + return ChangedLessonTile( + lesson, + onTap: () => TimetablePage.jump(context, lesson: lesson), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.dart new file mode 100755 index 0000000..4697ece --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.dart @@ -0,0 +1,80 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:flutter/material.dart'; +import 'lesson_view.i18n.dart'; + +class LessonView extends StatelessWidget { + const LessonView(this.lesson, {Key? key}) : super(key: key); + + final Lesson lesson; + + @override + Widget build(BuildContext context) { + Color accent = Theme.of(context).colorScheme.secondary; + String lessonIndexTrailing = ""; + + if (RegExp(r'\d').hasMatch(lesson.lessonIndex)) lessonIndexTrailing = "."; + + if (lesson.substituteTeacher != "") { + accent = AppColors.of(context).yellow; + } + + if (lesson.status?.name == "Elmaradt") { + accent = AppColors.of(context).red; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + ListTile( + leading: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + lesson.lessonIndex + lessonIndexTrailing, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 38.0, + fontWeight: FontWeight.w600, + color: accent, + ), + ), + ), + title: Text( + lesson.subject.renamedTo ?? lesson.subject.name.capital(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.w600, fontStyle: lesson.subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: Text( + lesson.substituteTeacher == "" ? lesson.teacher : lesson.substituteTeacher, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + lesson.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Details + if (lesson.room != "") Detail(title: "Room".i18n, description: lesson.room.replaceAll("_", " ")), + if (lesson.description != "") Detail(title: "Description".i18n, description: lesson.description), + if (lesson.lessonYearIndex != null) Detail(title: "Lesson Number".i18n, description: "${lesson.lessonYearIndex}."), + if (lesson.groupName != "") Detail(title: "Group".i18n, description: lesson.groupName), + ], + ), + ); + } + + static show(Lesson lesson, {required BuildContext context}) { + showBottomCard(context: context, child: LessonView(lesson)); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.i18n.dart new file mode 100755 index 0000000..7cce274 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_view.i18n.dart @@ -0,0 +1,30 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Room": "Room", + "Description": "Description", + "Lesson Number": "Lesson Number", + "Group": "Group", + }, + "hu_hu": { + "Room": "Terem", + "Description": "Leírás", + "Lesson Number": "Éves óraszám", + "Group": "Csoport", + }, + "de_de": { + "Room": "Raum", + "Description": "Bezeichnung", + "Lesson Number": "Ordinalzahl", + "Group": "Gruppe", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_viewable.dart new file mode 100755 index 0000000..e005a85 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/lesson/lesson_viewable.dart @@ -0,0 +1,25 @@ +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/card_handle.dart'; +import 'package:filcnaplo/ui/widgets/lesson/lesson_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/lesson/lesson_view.dart'; +import 'package:flutter/material.dart'; + +class LessonViewable extends StatelessWidget { + const LessonViewable(this.lesson, {Key? key, this.swapDesc = false}) : super(key: key); + + final Lesson lesson; + final bool swapDesc; + + @override + Widget build(BuildContext context) { + final tile = LessonTile(lesson, swapDesc: swapDesc); + + if (lesson.subject.id == '' || tile.lesson.isEmpty) return tile; + + return Viewable( + tile: tile, + view: CardHandle(child: LessonView(lesson)), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/attachment_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/attachment_tile.dart new file mode 100755 index 0000000..315a064 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/attachment_tile.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:filcnaplo_kreta_api/models/attachment.dart'; +import 'package:filcnaplo/helpers/attachment_helper.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/image_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class AttachmentTile extends StatelessWidget { + const AttachmentTile(this.attachment, {Key? key}) : super(key: key); + + final Attachment attachment; + + Widget buildImage(BuildContext context) { + return FutureBuilder( + future: attachment.download(context), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Material( + child: InkWell( + onTap: () { + showModalBottomSheet( + useRootNavigator: true, + isScrollControlled: true, + context: context, + builder: (context) { + return ImageView(snapshot.data!); + }, + ); + }, + child: Ink.image( + image: FileImage(File(snapshot.data ?? "")), + height: 200.0, + width: double.infinity, + fit: BoxFit.cover, + ), + borderRadius: BorderRadius.circular(12.0), + ), + ), + ), + ); + } else { + return Center( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: CircularProgressIndicator(color: Theme.of(context).colorScheme.secondary), + )); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + if (attachment.isImage) return buildImage(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: InkWell( + borderRadius: BorderRadius.circular(12.0), + onTap: () { + attachment.open(context); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [ + const Icon(FeatherIcons.paperclip), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Text(attachment.name, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ), + ]), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/image_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/image_view.dart new file mode 100755 index 0000000..459e3c2 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/image_view.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:filcnaplo/helpers/share_helper.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:photo_view/photo_view.dart'; + +class ImageView extends StatelessWidget { + const ImageView(this.path, {Key? key}) : super(key: key); + + final String path; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: SafeArea( + minimum: const EdgeInsets.only(top: 24.0), + child: Scaffold( + appBar: AppBar( + leading: BackButton(color: AppColors.of(context).text), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () => ShareHelper.shareFile(path), + icon: Icon(FeatherIcons.share2, color: AppColors.of(context).text), + splashRadius: 24.0, + ), + ), + ], + ), + body: PhotoView( + imageProvider: FileImage(File(path)), + maxScale: 4.0, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/message_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view.dart new file mode 100755 index 0000000..e66b453 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view.dart @@ -0,0 +1,53 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/message.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/message_view_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class MessageView extends StatefulWidget { + const MessageView(this.messages, {Key? key}) : super(key: key); + + final List messages; + + static show(List messages, {required BuildContext context}) => + Navigator.of(context, rootNavigator: true).push(CupertinoPageRoute(builder: (context) => MessageView(messages))); + + @override + _MessageViewState createState() => _MessageViewState(); +} + +class _MessageViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leadingWidth: 64.0, + leading: BackButton(color: AppColors.of(context).text), + elevation: 0, + actions: const [ + // Padding( + // padding: EdgeInsets.only(right: 8.0), + // child: IconButton( + // onPressed: () {}, + // icon: Icon(FeatherIcons.archive, color: AppColors.of(context).text), + // splashRadius: 32.0, + // ), + // ), + ], + ), + body: SafeArea( + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemCount: widget.messages.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: MessageViewTile(widget.messages[index]), + ); + }, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.dart new file mode 100755 index 0000000..ad49131 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.dart @@ -0,0 +1,122 @@ +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo_kreta_api/models/message.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/attachment_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +// import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:provider/provider.dart'; +import 'message_view_tile.i18n.dart'; + +class MessageViewTile extends StatelessWidget { + const MessageViewTile(this.message, {Key? key}) : super(key: key); + + final Message message; + + @override + Widget build(BuildContext context) { + UserProvider user = Provider.of(context, listen: false); + String recipientLabel = ""; + + if (message.recipients.any((r) => r.name == user.student?.name)) recipientLabel = "me".i18n; + + if (recipientLabel != "" && message.recipients.length > 1) { + recipientLabel += " +"; + recipientLabel += message.recipients.where((r) => r.name != user.student?.name).length.toString(); + } + + if (recipientLabel == "") { + // note: convertint to set to remove duplicates + recipientLabel += message.recipients.map((r) => r.name).toSet().join(", "); + } + + List attachments = []; + for (var a in message.attachments) { + attachments.add(AttachmentTile(a)); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Subject + Text( + message.subject, + softWrap: true, + maxLines: 10, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 24.0, + ), + ), + + // Author + ListTile( + visualDensity: VisualDensity.compact, + contentPadding: EdgeInsets.zero, + leading: ProfileImage( + name: message.author, + backgroundColor: ColorUtils.stringToColor(message.author), + ), + title: Text( + message.author, + style: const TextStyle(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + subtitle: Text( + "to".i18n + " " + recipientLabel, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + // IconButton( + // onPressed: () {}, + // icon: Icon(FeatherIcons.cornerUpLeft, color: AppColors.of(context).text), + // splashRadius: 24.0, + // padding: EdgeInsets.zero, + // visualDensity: VisualDensity.compact, + // ), + // IconButton( + // onPressed: () {}, + // icon: Icon(FeatherIcons.share2, color: AppColors.of(context).text), + // splashRadius: 24.0, + // padding: EdgeInsets.zero, + // visualDensity: VisualDensity.compact, + // ), + ], + ), + ), + + // Content + Panel( + padding: const EdgeInsets.all(12.0), + child: SelectableLinkify( + text: message.content.escapeHtml(), + options: const LinkifyOptions(looseUrl: true, removeWww: true), + onOpen: (link) { + launch(link.url, + customTabsOption: CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )); + }, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + + // Attachments + ...attachments, + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.i18n.dart new file mode 100755 index 0000000..828644a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/message_view_tile.i18n.dart @@ -0,0 +1,24 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "me": "me", + "to": "to", + }, + "hu_hu": { + "me": "én", + "to": "Címzett:", + }, + "de_de": { + "me": "mich", + "to": "zu", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/message/message_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/message/message_viewable.dart new file mode 100755 index 0000000..600e7be --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/message/message_viewable.dart @@ -0,0 +1,32 @@ +import 'package:animations/animations.dart'; +import 'package:filcnaplo_kreta_api/models/message.dart'; +import 'package:filcnaplo/ui/widgets/message/message_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/message_view.dart'; +import 'package:flutter/material.dart'; + +class MessageViewable extends StatelessWidget { + const MessageViewable(this.message, {Key? key}) : super(key: key); + + final Message message; + + @override + Widget build(BuildContext context) { + return OpenContainer( + openBuilder: (context, _) { + return MessageView([message]); + }, + closedBuilder: (context, VoidCallback openContainer) { + return MessageTile(message); + }, + closedElevation: 0, + openShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + closedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + middleColor: Theme.of(context).colorScheme.background, + openColor: Theme.of(context).scaffoldBackgroundColor, + closedColor: Theme.of(context).colorScheme.background, + transitionType: ContainerTransitionType.fadeThrough, + transitionDuration: const Duration(milliseconds: 400), + useRootNavigator: true, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.dart new file mode 100755 index 0000000..44ad9da --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.dart @@ -0,0 +1,51 @@ +import 'package:filcnaplo_kreta_api/models/note.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'miss_tile.i18n.dart'; + +class MissTile extends StatelessWidget { + const MissTile(this.note, {Key? key}) : super(key: key); + + final Note note; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(_missIcon(), color: Theme.of(context).colorScheme.secondary, size: 36.0), + visualDensity: VisualDensity.compact, + title: Text( + _missName(), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + note.content.split("órán nem volt")[0].capital(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ); + } + + IconData _missIcon() { + if (note.type?.name == "HaziFeladatHiany") { + return FeatherIcons.home; + } else if (note.type?.name == "Felszereleshiany") { + return FeatherIcons.book; + } + return FeatherIcons.slash; + } + + String _missName() { + if (note.type?.name == "HaziFeladatHiany") { + return "Missing homework".i18n; + } else if (note.type?.name == "Felszereleshiany") { + return "Missing equipment".i18n; + } + return "?"; + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.i18n.dart new file mode 100755 index 0000000..1f44a7c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/miss_tile.i18n.dart @@ -0,0 +1,24 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Missing homework": "Missing homework", + "Missing equipment": "Missing equipment", + }, + "hu_hu": { + "Missing homework": "Házi feladat hiány", + "Missing equipment": "Felszerelés Hiány", + }, + "de_de": { + "Missing homework": "Fehlende Hausaufgaben", + "Missing equipment": "Fehlende Ausrüstung", + } + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.dart new file mode 100755 index 0000000..46c492c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'missed_exam_tile.i18n.dart'; + +class MissedExamTile extends StatelessWidget { + const MissedExamTile(this.missedExams, {Key? key, this.onTap, this.padding}) : super(key: key); + + final List missedExams; + final Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: PanelButton( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6), + leading: SizedBox( + width: 36, + height: 36, + child: Icon( + FeatherIcons.slash, + color: AppColors.of(context).red.withOpacity(.75), + size: 28.0, + )), + title: Text("missed_exams".plural(missedExams.length).fill([missedExams.length])), + trailing: const Icon(FeatherIcons.arrowRight), + onPressed: onTap, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.i18n.dart new file mode 100755 index 0000000..2362b96 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_tile.i18n.dart @@ -0,0 +1,63 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "goodmorning": "Good morning, %s!", + "goodafternoon": "Good afternoon, %s!", + "goodevening": "Good evening, %s!", + "goodrest": "⛱️ Have a nice holiday, %s!", + "happybirthday": "🎂 Happy birthday, %s!", + "merryxmas": "🎄 Merry Christmas, %s!", + "happynewyear": "🎉 Happy New Year, %s!", + "empty": "Nothing to see here.", + "All": "All", + "Grades": "Grades", + "Messages": "Messages", + "Absences": "Absences", + "update_available": "Update Available", + "missed_exams": "You missed %s exams this week.".one("You missed an exam this week."), + "missed_exam_contact": "Contact %s, to resolve it!", + }, + "hu_hu": { + "goodmorning": "Jó reggelt, %s!", + "goodafternoon": "Szép napot, %s!", + "goodevening": "Szép estét, %s!", + "goodrest": "⛱️ Jó szünetet, %s!", + "happybirthday": "🎂 Boldog születésnapot, %s!", + "merryxmas": "🎄 Boldog Karácsonyt, %s!", + "happynewyear": "🎉 Boldog új évet, %s!", + "empty": "Nincs itt semmi látnivaló.", + "All": "Összes", + "Grades": "Jegyek", + "Messages": "Üzenetek", + "Absences": "Hiányok", + "update_available": "Frissítés elérhető", + "missed_exams": "Ezen a héten hiányoztál %s dolgozatról.".one("Ezen a héten hiányoztál egy dolgozatról."), + "missed_exam_contact": "Keresd %s-t, ha pótolni szeretnéd!", + }, + "de_de": { + "goodmorning": "Guten morgen, %s!", + "goodafternoon": "Guten Tag, %s!", + "goodevening": "Guten Abend, %s!", + "goodrest": "⛱️ Schöne Ferien, %s!", + "happybirthday": "🎂 Alles Gute zum Geburtstag, %s!", + "merryxmas": "🎄 Frohe Weihnachten, %s!", + "happynewyear": "🎉 Frohes neues Jahr, %s!", + "empty": "Hier gibt es nichts zu sehen.", + "All": "Alles", + "Grades": "Noten", + "Messages": "Nachrichten", + "Absences": "Fehlen", + "update_available": "Update verfügbar", + "missed_exams": "Diese Woche haben Sie %s Prüfungen verpasst.".one("Diese Woche haben Sie eine Prüfung verpasst."), + "missed_exam_contact": "Wenden Sie sich an %s, um sie zu erneuern!", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_view.dart new file mode 100755 index 0000000..2c40320 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_view.dart @@ -0,0 +1,61 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'missed_exam_tile.i18n.dart'; + +class MissedExamView extends StatelessWidget { + const MissedExamView(this.missedExams, {Key? key}) : super(key: key); + + final List missedExams; + + static show(List missedExams, {required BuildContext context}) => showRoundedModalBottomSheet(context, child: MissedExamView(missedExams)); + + @override + Widget build(BuildContext context) { + List tiles = missedExams.map((e) => MissedExamViewTile(e)).toList(); + return Column(children: tiles); + } +} + +class MissedExamViewTile extends StatelessWidget { + const MissedExamViewTile(this.lesson, {Key? key, this.padding}) : super(key: key); + + final EdgeInsetsGeometry? padding; + final Lesson lesson; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + leading: Icon( + SubjectIcon.resolveVariant(subject: lesson.subject, context: context), + color: AppColors.of(context).text.withOpacity(.8), + size: 32.0, + ), + title: Text( + "${lesson.subject.renamedTo ?? lesson.subject.name.capital()} • ${lesson.date.format(context)}", + style: TextStyle(fontWeight: FontWeight.w600, fontStyle: lesson.subject.isRenamed ? FontStyle.italic : null), + ), + subtitle: Text( + "missed_exam_contact".i18n.fill([lesson.teacher]), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: const Icon(FeatherIcons.arrowRight), + onTap: () { + Navigator.of(context, rootNavigator: true).pop(); + TimetablePage.jump(context, lesson: lesson); + }, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_viewable.dart new file mode 100755 index 0000000..c408728 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/missed_exam/missed_exam_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/missed_exam/missed_exam_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/missed_exam/missed_exam_view.dart'; +import 'package:flutter/material.dart'; + +class MissedExamViewable extends StatelessWidget { + const MissedExamViewable(this.missedExams, {Key? key}) : super(key: key); + + final List missedExams; + + @override + Widget build(BuildContext context) { + return MissedExamTile( + missedExams, + onTap: () => MissedExamView.show(missedExams, context: context), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/note/note_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/note/note_tile.dart new file mode 100755 index 0000000..148dace --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/note/note_tile.dart @@ -0,0 +1,46 @@ +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo_kreta_api/models/note.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:flutter/material.dart'; + +class NoteTile extends StatelessWidget { + const NoteTile(this.note, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Note note; + final void Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + leading: ProfileImage( + name: note.teacher, + radius: 22.0, + backgroundColor: ColorUtils.stringToColor(note.teacher), + ), + title: Text( + note.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + note.content.replaceAll('\n', ' '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + minLeadingWidth: 0, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/note/note_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/note/note_view.dart new file mode 100755 index 0000000..5794c25 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/note/note_view.dart @@ -0,0 +1,72 @@ +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo_kreta_api/models/note.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_mobile_ui/common/sliding_bottom_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; + +class NoteView extends StatelessWidget { + const NoteView(this.note, {Key? key}) : super(key: key); + + final Note note; + + static void show(Note note, {required BuildContext context}) => showSlidingBottomSheet(context: context, child: NoteView(note)); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + ListTile( + leading: ProfileImage( + name: note.teacher, + radius: 22.0, + backgroundColor: ColorUtils.stringToColor(note.teacher), + ), + title: Text( + note.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + note.teacher, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Text( + note.date.format(context), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + + // Details + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SelectableLinkify( + text: note.content.escapeHtml(), + options: const LinkifyOptions(looseUrl: true, removeWww: true), + onOpen: (link) { + launch(link.url, + customTabsOption: CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )); + }, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/note/note_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/note/note_viewable.dart new file mode 100755 index 0000000..42dc25e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/note/note_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo_kreta_api/models/note.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/note/note_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/note/note_view.dart'; +import 'package:flutter/material.dart'; + +class NoteViewable extends StatelessWidget { + const NoteViewable(this.note, {Key? key}) : super(key: key); + + final Note note; + + @override + Widget build(BuildContext context) { + return NoteTile( + note, + onTap: () => NoteView.show(note, context: context), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/statistics_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/statistics_tile.dart new file mode 100755 index 0000000..cc9fb2a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/statistics_tile.dart @@ -0,0 +1,107 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:i18n_extension/i18n_widget.dart'; + +class StatisticsTile extends StatelessWidget { + const StatisticsTile({ + Key? key, + required this.value, + this.title, + this.decimal = true, + this.color, + this.valueSuffix = '', + this.fill = false, + this.outline = false, + }) : super(key: key); + + final double value; + final Widget? title; + final bool decimal; + final Color? color; + final String valueSuffix; + final bool fill; + final bool outline; + + @override + Widget build(BuildContext context) { + String valueText; + if (decimal) { + valueText = value.toStringAsFixed(2); + } else { + valueText = value.toStringAsFixed(0); + } + if (I18n.of(context).locale.languageCode != "en") valueText = valueText.replaceAll(".", ","); + + if (value.isNaN) { + valueText = "?"; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: Theme.of(context).colorScheme.background, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + constraints: const BoxConstraints( + minHeight: 140.0, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (title != null) + DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + fontSize: 18.0, + ), + child: title!, + ), + if (title != null) const SizedBox(height: 4.0), + Container( + margin: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + decoration: BoxDecoration( + color: fill ? (color ?? gradeColor(context: context, value: value)).withOpacity(.2) : null, + border: outline || fill + ? Border.all( + color: (color ?? gradeColor(context: context, value: value)).withOpacity(outline ? 1.0 : 0.0), + width: fill ? 2.0 : 5.0, + ) + : null, + borderRadius: BorderRadius.circular(45.0), + ), + child: AutoSizeText.rich( + TextSpan( + text: valueText, + children: [ + if (valueSuffix != "") + TextSpan( + text: valueSuffix, + style: const TextStyle(fontSize: 24.0), + ), + ], + ), + maxLines: 1, + minFontSize: 5, + textAlign: TextAlign.center, + style: TextStyle( + color: color ?? gradeColor(context: context, value: value), + fontWeight: FontWeight.w800, + fontSize: 32.0, + ), + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.dart b/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.dart new file mode 100755 index 0000000..1977101 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.dart @@ -0,0 +1,32 @@ +import 'package:filcnaplo/models/release.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'update_tile.i18n.dart'; + +class UpdateTile extends StatelessWidget { + const UpdateTile(this.release, {Key? key, this.onTap, this.padding}) : super(key: key); + + final Release release; + final Function()? onTap; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0), + child: PanelButton( + onPressed: onTap, + title: Text("update_available".i18n), + leading: const Icon(FeatherIcons.download), + trailing: Text( + release.tag, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.i18n.dart new file mode 100755 index 0000000..be1183a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/update/update_tile.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "update_available": "Update Available", + }, + "hu_hu": { + "update_available": "Frissítés elérhető", + }, + "de_de": { + "update_available": "Update verfügbar", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/update/update_viewable.dart b/filcnaplo_mobile_ui/lib/common/widgets/update/update_viewable.dart new file mode 100755 index 0000000..4682e74 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/update/update_viewable.dart @@ -0,0 +1,18 @@ +import 'package:filcnaplo/models/release.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/update/update_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/update/updates_view.dart'; +import 'package:flutter/material.dart'; + +class UpdateViewable extends StatelessWidget { + const UpdateViewable(this.release, {Key? key}) : super(key: key); + + final Release release; + + @override + Widget build(BuildContext context) { + return UpdateTile( + release, + onTap: () => UpdateView.show(release, context: context), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.dart b/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.dart new file mode 100755 index 0000000..71c7779 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.dart @@ -0,0 +1,170 @@ +import 'package:filcnaplo/api/providers/status_provider.dart'; +import 'package:filcnaplo/models/release.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/material_action_button.dart'; +import 'package:filcnaplo/helpers/update_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'updates_view.i18n.dart'; + +class UpdateView extends StatefulWidget { + const UpdateView(this.release, {Key? key}) : super(key: key); + + final Release release; + + static void show(Release release, {required BuildContext context}) => showBottomCard(context: context, child: UpdateView(release)); + + @override + _UpdateViewState createState() => _UpdateViewState(); +} + +class _UpdateViewState extends State { + double progress = 0.0; + UpdateState state = UpdateState.none; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "new_update".i18n, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 18.0), + ), + Text( + "${widget.release.version}", + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(0.6), + ), + ), + ], + ), + ClipRRect( + borderRadius: BorderRadius.circular(18.0), + child: Image.asset( + "assets/icons/ic_launcher.png", + width: 64.0, + ), + ) + ], + ), + ), + + // Description + Container( + margin: const EdgeInsets.only(top: 8.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + ), + child: SizedBox( + height: 125.0, + child: Markdown( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + physics: const BouncingScrollPhysics(), + data: widget.release.body, + onTapLink: (text, href, title) => launch(href ?? ""), + ), + ), + ), + + // Download button + Center( + child: MaterialActionButton( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state == UpdateState.downloading || state == UpdateState.preparing) + Container( + height: 18.0, + width: 18.0, + margin: const EdgeInsets.only(right: 8.0), + child: CircularProgressIndicator( + value: progress > 0.05 ? progress : null, + color: ColorUtils.foregroundColor(AppColors.of(context).filc), + ), + ), + Text(["download".i18n, "downloading".i18n, "downloading".i18n, "installing".i18n][state.index].toUpperCase()), + ], + ), + backgroundColor: AppColors.of(context).filc, + onPressed: state == UpdateState.none ? () => downloadPrecheck() : null, + ), + ), + ], + ), + ); + } + + String fmtSize() => "${(widget.release.downloads.first.size / 1024 / 1024).toStringAsFixed(1)} MB"; + + void downloadPrecheck() { + final status = Provider.of(context, listen: false); + if (status.networkType == ConnectivityResult.mobile) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("mobileAlertTitle".i18n), + content: Text("mobileAlertDesc".i18n.fill([fmtSize()])), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text("no".i18n), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text("yes".i18n), + ), + ], + ), + ).then((value) => value ? download() : null); + } else { + download(); + } + } + + void download() { + widget.release + .install(updateCallback: (p, s) { + if (mounted) { + setState(() { + progress = p; + state = s; + }); + } + }) + .then((_) => Navigator.of(context).maybePop()) + .catchError((error, stackTrace) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + context: context, + content: Text("error".i18n), + backgroundColor: AppColors.of(context).red, + )); + setState(() => state = UpdateState.none); + } + return true; + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.i18n.dart b/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.i18n.dart new file mode 100755 index 0000000..012924b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/common/widgets/update/updates_view.i18n.dart @@ -0,0 +1,46 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "new_update": "New Update", + "download": "download", + "downloading": "downloading", + "installing": "installing", + "error": "Failed to install update!", + "no": "No", + "yes": "Yes", + "mobileAlertTitle": "Hold up!", + "mobileAlertDesc": "You're on mobile network trying to download a %s update. Are you sure you want to continue?" + }, + "hu_hu": { + "new_update": "Új frissítés", + "download": "Letöltés", + "downloading": "Letöltés", + "installing": "Telepítés", + "error": "Nem sikerült telepíteni a frissítést!", + "no": "Nem", + "yes": "Igen", + "mobileAlertTitle": "Figyelem!", + "mobileAlertDesc": "Jelenleg mobil interneten vagy, és egy %s méretű frissítést próbálsz letölteni. Biztosan folytatod?" + }, + "de_de": { + "new_update": "Neues Update", + "download": "herunterladen", + "downloading": "Herunterladen", + "installing": "Installation", + "error": "Update konnte nicht installiert werden!", + "no": "Nein", + "yes": "Ja", + "mobileAlertTitle": "Achtung!", + "mobileAlertDesc": + "Sie befinden sich gerade im mobilen Internet und versuchen, ein %s Update herunterzuladen. Sind Sie sicher, dass Sie weitermachen wollen?" + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view.dart b/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view.dart new file mode 100755 index 0000000..2ebde2e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view.dart @@ -0,0 +1,79 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/ui/date_widget.dart'; +import 'package:filcnaplo/utils/reverse_search.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/hero_scrollview.dart'; +import 'package:filcnaplo_mobile_ui/pages/absences/absence_subject_view_container.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:filcnaplo/ui/filter/sort.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; + +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_view.i18n.dart'; + +class AbsenceSubjectView extends StatelessWidget { + const AbsenceSubjectView(this.subject, {Key? key, this.absences = const []}) : super(key: key); + + final Subject subject; + final List absences; + + static void show(Subject subject, List absences, {required BuildContext context}) { + Navigator.of(context, rootNavigator: true) + .push(CupertinoPageRoute(builder: (context) => AbsenceSubjectView(subject, absences: absences))) + .then((value) { + if (value == null) return; + + Future.delayed(const Duration(milliseconds: 250)).then((_) { + ReverseSearch.getLessonByAbsence(value, context).then((lesson) { + if (lesson != null) { + TimetablePage.jump(context, lesson: lesson); + } else { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + content: Text("Cannot find lesson".i18n, style: const TextStyle(color: Colors.white)), + backgroundColor: AppColors.of(context).red, + context: context, + )); + } + }); + }); + }); + } + + @override + Widget build(BuildContext context) { + final dateWidgets = absences + .map((a) => DateWidget( + widget: AbsenceViewable(a, padding: EdgeInsets.zero), + date: a.date, + )) + .toList(); + List absenceTiles = sortDateWidgets(context, dateWidgets: dateWidgets, padding: EdgeInsets.zero, hasShadow: true); + + return Scaffold( + body: HeroScrollView( + title: subject.renamedTo ?? subject.name.capital(), + italic: subject.isRenamed, + icon: SubjectIcon.resolveVariant(subject: subject, context: context), + child: AbsenceSubjectViewContainer( + child: CupertinoScrollbar( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(24.0), + shrinkWrap: true, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: absenceTiles[index], + ), + itemCount: absenceTiles.length, + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view_container.dart b/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view_container.dart new file mode 100755 index 0000000..53836c7 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/absences/absence_subject_view_container.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class AbsenceSubjectViewContainer extends InheritedWidget { + const AbsenceSubjectViewContainer({Key? key, required Widget child}) : super(key: key, child: child); + + static AbsenceSubjectViewContainer? of(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(AbsenceSubjectViewContainer oldWidget) => false; +} diff --git a/filcnaplo_mobile_ui/lib/pages/absences/absences_page.dart b/filcnaplo_mobile_ui/lib/pages/absences/absences_page.dart new file mode 100755 index 0000000..48da5d0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/absences/absences_page.dart @@ -0,0 +1,382 @@ +import 'dart:math'; + +import 'package:animations/animations.dart'; +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo/ui/date_widget.dart'; +import 'package:filcnaplo_kreta_api/models/absence.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/models/week.dart'; +import 'package:filcnaplo_kreta_api/providers/absence_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/note_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/action_button.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/filter_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_subject_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/statistics_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/miss_tile.dart'; +import 'package:filcnaplo_mobile_ui/pages/absences/absence_subject_view.dart'; +import 'package:filcnaplo/ui/filter/sort.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'absences_page.i18n.dart'; + +enum AbsenceFilter { absences, delays, misses } + +class SubjectAbsence { + Subject subject; + List absences; + double percentage; + + SubjectAbsence({required this.subject, this.absences = const [], this.percentage = 0.0}); +} + +class AbsencesPage extends StatefulWidget { + const AbsencesPage({Key? key}) : super(key: key); + + @override + _AbsencesPageState createState() => _AbsencesPageState(); +} + +class _AbsencesPageState extends State with TickerProviderStateMixin { + late UserProvider user; + late AbsenceProvider absenceProvider; + late TimetableProvider timetableProvider; + late NoteProvider noteProvider; + late UpdateProvider updateProvider; + late String firstName; + late TabController _tabController; + late List absences = []; + final Map _lessonCount = {}; + + @override + void initState() { + super.initState(); + + _tabController = TabController(length: 3, vsync: this); + timetableProvider = Provider.of(context, listen: false); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + for (final lesson in timetableProvider.getWeek(Week.current()) ?? []) { + if (!lesson.isEmpty && lesson.subject.id != '' && lesson.lessonYearIndex != null) { + _lessonCount.update( + lesson.subject, + (value) { + if (lesson.lessonYearIndex! > value.lessonYearIndex!) { + return lesson; + } else { + return value; + } + }, + ifAbsent: () => lesson, + ); + } + } + setState(() {}); + }); + } + + void buildSubjectAbsences() { + Map _absences = {}; + + for (final absence in absenceProvider.absences) { + if (absence.delay != 0) continue; + + if (!_absences.containsKey(absence.subject)) { + _absences[absence.subject] = SubjectAbsence(subject: absence.subject, absences: [absence]); + } else { + _absences[absence.subject]?.absences.add(absence); + } + } + + _absences.forEach((subject, absence) { + final absentLessonsOfSubject = absenceProvider.absences.where((e) => e.subject == subject && e.delay == 0).length; + final totalLessonsOfSubject = _lessonCount[subject]?.lessonYearIndex ?? 0; + + double absentLessonsOfSubjectPercentage; + + if (absentLessonsOfSubject <= totalLessonsOfSubject) { + absentLessonsOfSubjectPercentage = absentLessonsOfSubject / totalLessonsOfSubject * 100; + } else { + absentLessonsOfSubjectPercentage = -1; + } + + _absences[subject]?.percentage = absentLessonsOfSubjectPercentage.clamp(-1, 100.0); + }); + + absences = _absences.values.toList(); + absences.sort((a, b) => -a.percentage.compareTo(b.percentage)); + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + absenceProvider = Provider.of(context); + noteProvider = Provider.of(context); + updateProvider = Provider.of(context); + timetableProvider = Provider.of(context); + + List nameParts = user.displayName?.split(" ") ?? ["?"]; + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + + buildSubjectAbsences(); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: NestedScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + SliverAppBar( + pinned: true, + floating: false, + snap: false, + centerTitle: false, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + actions: [ + // Profile Icon + Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ProfileButton( + child: ProfileImage( + heroTag: "profile", + name: firstName, + backgroundColor: ColorUtils.stringToColor(user.displayName ?? "?"), + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + ), + ), + ), + ], + automaticallyImplyLeading: false, + shadowColor: Theme.of(context).shadowColor, + title: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + "Absences".i18n, + style: TextStyle(color: AppColors.of(context).text, fontSize: 32.0, fontWeight: FontWeight.bold), + ), + ), + bottom: FilterBar(items: [ + Tab(text: "Absences".i18n), + Tab(text: "Delays".i18n), + Tab(text: "Misses".i18n), + ], controller: _tabController, disableFading: true), + ), + ], + body: TabBarView( + physics: const BouncingScrollPhysics(), + controller: _tabController, + children: List.generate(3, (index) => filterViewBuilder(context, index))), + ), + ), + ); + } + + List getFilterWidgets(AbsenceFilter activeData) { + List items = []; + switch (activeData) { + case AbsenceFilter.absences: + for (var a in absences) { + items.add(DateWidget( + date: DateTime.fromMillisecondsSinceEpoch(0), + widget: AbsenceSubjectTile( + a.subject, + percentage: a.percentage, + excused: a.absences.where((a) => a.state == Justification.excused).length, + unexcused: a.absences.where((a) => a.state == Justification.unexcused).length, + pending: a.absences.where((a) => a.state == Justification.pending).length, + onTap: () => AbsenceSubjectView.show(a.subject, a.absences, context: context), + ), + )); + } + break; + case AbsenceFilter.delays: + for (var absence in absenceProvider.absences) { + if (absence.delay != 0) { + items.add(DateWidget( + date: absence.date, + widget: AbsenceViewable(absence, padding: EdgeInsets.zero), + )); + } + } + break; + case AbsenceFilter.misses: + for (var note in noteProvider.notes) { + if (note.type?.name == "HaziFeladatHiany" || note.type?.name == "Felszereleshiany") { + items.add(DateWidget( + date: note.date, + widget: MissTile(note), + )); + } + } + break; + } + return items; + } + + Widget filterViewBuilder(context, int activeData) { + List filterWidgets = []; + + if (activeData > 0) { + filterWidgets = sortDateWidgets( + context, + dateWidgets: getFilterWidgets(AbsenceFilter.values[activeData]), + padding: EdgeInsets.zero, + hasShadow: true, + ); + } else { + filterWidgets = [ + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Panel( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Subjects".i18n), + Padding( + padding: const EdgeInsets.only(right: 4.0), + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Text("attention".i18n), + content: Text("attention_body".i18n), + actions: [ActionButton(label: "Ok", onTap: () => Navigator.of(context).pop())], + ), + ); + }, + padding: EdgeInsets.zero, + splashRadius: 24.0, + visualDensity: VisualDensity.compact, + constraints: BoxConstraints.tight(const Size(42.0, 42.0)), + icon: const Icon(FeatherIcons.info), + ), + ), + ], + ), + child: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return FadeThroughTransition( + child: child, + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + fillColor: Theme.of(context).colorScheme.background, + ); + }, + child: Column( + children: getFilterWidgets(AbsenceFilter.values[activeData]).map((e) => e.widget).cast().toList(), + ), + ), + ), + ) + ]; + } + + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: RefreshIndicator( + color: Theme.of(context).colorScheme.secondary, + onRefresh: () async { + await absenceProvider.fetch(); + await noteProvider.fetch(); + }, + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemCount: max(filterWidgets.length + (activeData <= 1 ? 1 : 0), 1), + itemBuilder: (context, index) { + if (filterWidgets.isNotEmpty) { + if ((index == 0 && activeData == 1) || (index == 0 && activeData == 0)) { + int value1 = 0; + int value2 = 0; + String title1 = ""; + String title2 = ""; + String suffix = ""; + + if (activeData == AbsenceFilter.absences.index) { + value1 = absenceProvider.absences.where((e) => e.delay == 0 && e.state == Justification.excused).length; + value2 = absenceProvider.absences.where((e) => e.delay == 0 && e.state == Justification.unexcused).length; + title1 = "stat_1".i18n; + title2 = "stat_2".i18n; + suffix = " " + "hr".i18n; + } else if (activeData == AbsenceFilter.delays.index) { + value1 = absenceProvider.absences + .where((e) => e.delay != 0 && e.state == Justification.excused) + .map((e) => e.delay) + .fold(0, (a, b) => a + b); + value2 = absenceProvider.absences + .where((e) => e.delay != 0 && e.state == Justification.unexcused) + .map((e) => e.delay) + .fold(0, (a, b) => a + b); + title1 = "stat_3".i18n; + title2 = "stat_4".i18n; + suffix = " " + "min".i18n; + } + + return Padding( + padding: const EdgeInsets.only(bottom: 24.0, left: 24.0, right: 24.0), + child: Row(children: [ + Expanded( + child: StatisticsTile( + title: AutoSizeText( + title1, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + valueSuffix: suffix, + value: value1.toDouble(), + decimal: false, + color: AppColors.of(context).green, + ), + ), + const SizedBox(width: 24.0), + Expanded( + child: StatisticsTile( + title: AutoSizeText( + title2, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + valueSuffix: suffix, + value: value2.toDouble(), + decimal: false, + color: AppColors.of(context).red, + ), + ), + ]), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0), + child: filterWidgets[index - (activeData <= 1 ? 1 : 0)], + ); + } else { + return Empty(subtitle: "empty".i18n); + } + }, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/absences/absences_page.i18n.dart b/filcnaplo_mobile_ui/lib/pages/absences/absences_page.i18n.dart new file mode 100755 index 0000000..2b37c00 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/absences/absences_page.i18n.dart @@ -0,0 +1,57 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension ScreensLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Absences": "Absences", + "Delays": "Delays", + "Misses": "Misses", + "empty": "You have no absences.", + "stat_1": "Excused Absences", + "stat_2": "Unexcused Absences", + "stat_3": "Excused Delay", + "stat_4": "Unexcused Delay", + "min": "min", + "hr": "hrs", + "Subjects": "Subjects", + "attention": "Attention!", + "attention_body": "Percentage calculations are only an approximation so they may not be accurate.", + }, + "hu_hu": { + "Absences": "Hiányzások", + "Delays": "Késések", + "Misses": "Hiányok", + "empty": "Nincsenek hiányaid.", + "stat_1": "Igazolt hiányzások", + "stat_2": "Igazolatlan hiányzások", + "stat_3": "Igazolt Késés", + "stat_4": "Igazolatlan Késés", + "min": "perc", + "hr": "óra", + "Subjects": "Tantárgyak", + "attention": "Figyelem!", + "attention_body": "A százalékos számítások csak közelítések, ezért előfordulhat, hogy nem pontosak.", + }, + "de_de": { + "Absences": "Fehlen", + "Delays": "Verspätung", + "Misses": "Fehlt", + "empty": "Sie haben keine Fehlen.", + "stat_1": "Entschuldigte Fehlen", + "stat_2": "Unentschuldigte Fehlen", + "stat_3": "Entschuldigte Verspätung", + "stat_4": "Unentschuldigte Verspätung", + "min": "min", + "hr": "hrs", + "Subjects": "Fächer", + "attention": "Achtung!", + "attention_body": "Prozentberechnungen sind nur eine Annäherung und können daher ungenau sein.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.dart b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.dart new file mode 100755 index 0000000..bd8fa95 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.dart @@ -0,0 +1,167 @@ +import 'dart:math'; + +import 'package:filcnaplo_kreta_api/models/category.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/material_action_button.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'grade_calculator.i18n.dart'; + +class GradeCalculator extends StatefulWidget { + const GradeCalculator(this.subject, {Key? key}) : super(key: key); + + final Subject subject; + + @override + _GradeCalculatorState createState() => _GradeCalculatorState(); +} + +class _GradeCalculatorState extends State { + late GradeCalculatorProvider calculatorProvider; + + final _weightController = TextEditingController(text: "100"); + + double newValue = 5.0; + double newWeight = 100.0; + + @override + Widget build(BuildContext context) { + calculatorProvider = Provider.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(6.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "Grade Calculator".i18n, + style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w600), + ), + ), + + // Grade value + Row(children: [ + Expanded( + child: Slider( + thumbColor: Theme.of(context).colorScheme.secondary, + activeColor: Theme.of(context).colorScheme.secondary, + value: newValue, + min: 1.0, + max: 5.0, + divisions: 4, + label: "${newValue.toInt()}", + onChanged: (value) => setState(() => newValue = value), + ), + ), + Container( + width: 80.0, + padding: const EdgeInsets.only(right: 12.0), + child: Center(child: GradeValueWidget(GradeValue(newValue.toInt(), "", "", 0))), + ), + ]), + + // Grade weight + Row(children: [ + Expanded( + child: Slider( + thumbColor: Theme.of(context).colorScheme.secondary, + activeColor: Theme.of(context).colorScheme.secondary, + value: newWeight.clamp(50, 400), + min: 50.0, + max: 400.0, + divisions: 7, + label: "${newWeight.toInt()}%", + onChanged: (value) => setState(() { + newWeight = value; + _weightController.text = newWeight.toInt().toString(); + }), + ), + ), + Container( + width: 80.0, + padding: const EdgeInsets.only(right: 12.0), + child: Center( + child: TextField( + controller: _weightController, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 22.0), + autocorrect: false, + textAlign: TextAlign.right, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + LengthLimitingTextInputFormatter(3), + ], + decoration: const InputDecoration( + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: "100", + suffixText: "%", + suffixStyle: TextStyle(fontSize: 18.0), + ), + onChanged: (value) { + setState(() { + newWeight = double.tryParse(value) ?? 100.0; + }); + }, + ), + ), + ), + ]), + Container( + width: 120.0, + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: MaterialActionButton( + child: Text("Add Grade".i18n), + onPressed: () { + if (calculatorProvider.ghosts.length >= 30) { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(content: Text("limit_reached".i18n), context: context)); + return; + } + + DateTime date; + + if (calculatorProvider.ghosts.isNotEmpty) { + List grades = calculatorProvider.ghosts; + grades.sort((a, b) => -a.writeDate.compareTo(b.writeDate)); + date = grades.first.date.add(const Duration(days: 7)); + } else { + List grades = calculatorProvider.grades.where((e) => e.type == GradeType.midYear && e.subject == widget.subject).toList(); + grades.sort((a, b) => -a.writeDate.compareTo(b.writeDate)); + date = grades.first.date; + } + + calculatorProvider.addGhost(Grade( + id: randomId(), + date: date, + writeDate: date, + description: "Ghost Grade".i18n, + value: GradeValue(newValue.toInt(), "", "", newWeight.toInt()), + teacher: "Ghost", + type: GradeType.ghost, + form: "", + subject: widget.subject, + mode: Category.fromJson({}), + seenDate: DateTime(0), + groupId: "", + )); + }, + ), + ), + ], + ), + ); + } + + String randomId() { + var rng = Random(); + return rng.nextInt(1000000000).toString(); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.i18n.dart b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.i18n.dart new file mode 100755 index 0000000..7c0565c --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator.i18n.dart @@ -0,0 +1,33 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Grades": "Grades", + "Ghost Grade": "Ghost Grade", + "Grade Calculator": "Average calculator", + "Add Grade": "Add Grade", + "limit_reached": "You cannot add more Ghost Grades.", + }, + "hu_hu": { + "Grades": "Jegyek", + "Ghost Grade": "Szellem jegy", + "Grade Calculator": "Átlag számoló", + "Add Grade": "Hozzáadás", + "limit_reached": "Nem adhatsz hozzá több jegyet.", + }, + "de_de": { + "Grades": "Noten", + "Ghost Grade": "Geist Noten", + "Grade Calculator": "Mittelwert-Rechner", + "Add Grade": "Hinzufügen", + "limit_reached": "Sie können keine weiteren Noten hinzufügen.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator_provider.dart b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator_provider.dart new file mode 100755 index 0000000..3b4611b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/calculator/grade_calculator_provider.dart @@ -0,0 +1,53 @@ +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_kreta_api/client/client.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; + +class GradeCalculatorProvider extends GradeProvider { + GradeCalculatorProvider({ + List initialGrades = const [], + required SettingsProvider settings, + required UserProvider user, + required DatabaseProvider database, + required KretaClient kreta, + }) : super( + initialGrades: initialGrades, + settings: settings, + database: database, + kreta: kreta, + user: user, + ); + + List _grades = []; + List _ghosts = []; + @override + List get grades => _grades + _ghosts; + List get ghosts => _ghosts; + + void addGhost(Grade grade) { + _ghosts.add(grade); + notifyListeners(); + } + + void addGrade(Grade grade) { + _grades.add(grade); + notifyListeners(); + } + + void removeGrade(Grade ghost) { + _ghosts.removeWhere((e) => ghost.id == e.id); + notifyListeners(); + } + + void addAllGrades(List grades) { + _grades.addAll(grades); + notifyListeners(); + } + + void clear() { + _grades = []; + _ghosts = []; + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/fail_warning.dart b/filcnaplo_mobile_ui/lib/pages/grades/fail_warning.dart new file mode 100755 index 0000000..145712f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/fail_warning.dart @@ -0,0 +1,39 @@ +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'grades_page.i18n.dart'; + +class FailWarning extends StatelessWidget { + const FailWarning({Key? key, required this.subjectAvgs}) : super(key: key); + + final Map subjectAvgs; + + @override + Widget build(BuildContext context) { + final failingSubjectCount = subjectAvgs.values.where((avg) => avg < 2.0).length; + + if (failingSubjectCount == 0) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Panel( + title: Text("fail_warning".i18n), + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + FeatherIcons.alertTriangle, + color: Colors.orange.withOpacity(.5), + size: 20.0, + ), + const SizedBox(width: 12.0), + Text("fail_warning_description".i18n.fill([failingSubjectCount])), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/grade_subject_view.dart b/filcnaplo_mobile_ui/lib/pages/grades/grade_subject_view.dart new file mode 100755 index 0000000..0d85ce0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/grade_subject_view.dart @@ -0,0 +1,283 @@ +import 'dart:math'; + +import 'package:animations/animations.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo/helpers/average_helper.dart'; +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/trend_display.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/cretification/certification_tile.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/grade/grade_viewable.dart'; +import 'package:filcnaplo_mobile_ui/common/hero_scrollview.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grades_count.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/graph.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/subject_grades_container.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'grades_page.i18n.dart'; +// import 'package:filcnaplo_premium/ui/mobile/goalplanner/new_goal.dart'; + +class GradeSubjectView extends StatefulWidget { + const GradeSubjectView(this.subject, {Key? key, this.groupAverage = 0.0}) : super(key: key); + + final Subject subject; + final double groupAverage; + + void push(BuildContext context, {bool root = false}) { + Navigator.of(context, rootNavigator: root).push(CupertinoPageRoute(builder: (context) => this)); + } + + @override + State createState() => _GradeSubjectViewState(); +} + +class _GradeSubjectViewState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + // Controllers + PersistentBottomSheetController? _sheetController; + final ScrollController _scrollController = ScrollController(); + + List gradeTiles = []; + + // Providers + late GradeProvider gradeProvider; + late GradeCalculatorProvider calculatorProvider; + + late double average; + late Widget gradeGraph; + + bool gradeCalcMode = false; + + List getSubjectGrades(Subject subject) => !gradeCalcMode + ? gradeProvider.grades.where((e) => e.subject == subject).toList() + : calculatorProvider.grades.where((e) => e.subject == subject).toList(); + + bool showGraph(List subjectGrades) { + if (gradeCalcMode) return true; + + final gradeDates = subjectGrades.map((e) => e.date.millisecondsSinceEpoch); + final maxGradeDate = gradeDates.fold(0, max); + final minGradeDate = gradeDates.fold(0, min); + if (maxGradeDate - minGradeDate < const Duration(days: 5).inMilliseconds) return false; // naplo/#78 + + return subjectGrades.where((e) => e.type == GradeType.midYear).length > 1; + } + + void buildTiles(List subjectGrades) { + List tiles = []; + + if (showGraph(subjectGrades)) { + tiles.add(gradeGraph); + } else { + tiles.add(Container(height: 24.0)); + } + + tiles.add(Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Panel( + child: GradesCount(grades: getSubjectGrades(widget.subject).toList()), + ), + )); + + List _gradeTiles = []; + + if (!gradeCalcMode) { + subjectGrades.sort((a, b) => -a.date.compareTo(b.date)); + for (var grade in subjectGrades) { + if (grade.type == GradeType.midYear) { + _gradeTiles.add(GradeViewable(grade)); + } else { + _gradeTiles.add(CertificationTile(grade, padding: EdgeInsets.zero)); + } + } + } else if (subjectGrades.isNotEmpty) { + subjectGrades.sort((a, b) => -a.date.compareTo(b.date)); + for (var grade in subjectGrades) { + _gradeTiles.add(GradeTile(grade)); + } + } + tiles.add( + PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.vertical, + child: child, + fillColor: Colors.transparent, + ); + }, + child: _gradeTiles.isNotEmpty + ? Panel( + key: ValueKey(gradeCalcMode), + title: Text( + gradeCalcMode ? "Ghost Grades".i18n : "Grades".i18n, + ), + child: Column( + children: _gradeTiles, + )) + : const SizedBox(), + ), + ); + + tiles.add(Padding(padding: EdgeInsets.only(bottom: !gradeCalcMode ? 24.0 : 250.0))); + gradeTiles = List.castFrom(tiles); + } + + @override + Widget build(BuildContext context) { + gradeProvider = Provider.of(context); + calculatorProvider = Provider.of(context); + + List subjectGrades = getSubjectGrades(widget.subject).toList(); + average = AverageHelper.averageEvals(subjectGrades); + final prevAvg = subjectGrades.isNotEmpty + ? AverageHelper.averageEvals(subjectGrades + .where((e) => e.date.isBefore(subjectGrades.reduce((v, e) => e.date.isAfter(v.date) ? e : v).date.subtract(const Duration(days: 30)))) + .toList()) + : 0.0; + + gradeGraph = Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Panel( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("annual_average".i18n), + if (average != prevAvg) TrendDisplay(current: average, previous: prevAvg), + ], + ), + child: Container( + padding: const EdgeInsets.only(top: 16.0, right: 12.0), + child: GradeGraph(subjectGrades, dayThreshold: 5, classAvg: widget.groupAverage), + ), + ), + ); + + if (!gradeCalcMode) { + buildTiles(subjectGrades); + } else { + List ghostGrades = calculatorProvider.ghosts.where((e) => e.subject == widget.subject).toList(); + buildTiles(ghostGrades); + } + + return Scaffold( + key: _scaffoldKey, + floatingActionButtonLocation: ExpandableFab.location, + floatingActionButton: Visibility( + visible: !gradeCalcMode && subjectGrades.where((e) => e.type == GradeType.midYear).isNotEmpty, + child: ExpandableFab( + backgroundColor: Theme.of(context).colorScheme.secondary, + type: ExpandableFabType.up, + distance: 50, + closeButtonStyle: ExpandableFabCloseButtonStyle( + backgroundColor: Theme.of(context).colorScheme.secondary, + ), + children: [ + FloatingActionButton.small( + child: const Icon(FeatherIcons.plus), + backgroundColor: Theme.of(context).colorScheme.secondary, + onPressed: () { + gradeCalc(context); + }, + ), + FloatingActionButton.small( + child: const Icon(FeatherIcons.flag, size: 20.0), + backgroundColor: Theme.of(context).colorScheme.secondary, + onPressed: () { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.goalPlanner)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.goalplanner); + return; + } + + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Hamarosan..."))); + + // Navigator.of(context).push(CupertinoPageRoute(builder: (context) => PremiumGoalplannerNewGoalScreen(subject: widget.subject))); + }, + ), + ], + ), + ), + body: RefreshIndicator( + onRefresh: () async {}, + color: Theme.of(context).colorScheme.secondary, + child: HeroScrollView( + onClose: () { + if (_sheetController != null && gradeCalcMode) { + _sheetController!.close(); + } else { + Navigator.of(context).pop(); + } + }, + navBarItems: [ + const SizedBox(width: 6.0), + if (widget.groupAverage != 0) Center(child: AverageDisplay(average: widget.groupAverage, border: true)), + const SizedBox(width: 6.0), + if (average != 0) Center(child: AverageDisplay(average: average)), + const SizedBox(width: 12.0), + ], + icon: SubjectIcon.resolveVariant(subject: widget.subject, context: context), + scrollController: _scrollController, + title: widget.subject.renamedTo ?? widget.subject.name.capital(), + italic: widget.subject.isRenamed, + child: SubjectGradesContainer( + child: CupertinoScrollbar( + child: ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24.0), + shrinkWrap: true, + itemBuilder: (context, index) => gradeTiles[index], + itemCount: gradeTiles.length, + ), + ), + )), + )); + } + + void gradeCalc(BuildContext context) { + // Scroll to the top of the page + _scrollController.animateTo(75, duration: const Duration(milliseconds: 500), curve: Curves.ease); + + calculatorProvider.clear(); + calculatorProvider.addAllGrades(gradeProvider.grades); + + _sheetController = _scaffoldKey.currentState?.showBottomSheet( + (context) => RoundedBottomSheet(child: GradeCalculator(widget.subject), borderRadius: 14.0), + backgroundColor: const Color(0x00000000), + elevation: 12.0, + ); + + // Hide the fab and grades + setState(() { + gradeCalcMode = true; + }); + + _sheetController!.closed.then((value) { + // Show fab and grades + if (mounted) { + setState(() { + gradeCalcMode = false; + }); + } + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/grades_count.dart b/filcnaplo_mobile_ui/lib/pages/grades/grades_count.dart new file mode 100755 index 0000000..50f78b7 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/grades_count.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grades_count_item.dart'; +import 'package:collection/collection.dart'; + +class GradesCount extends StatelessWidget { + const GradesCount({Key? key, required this.grades}) : super(key: key); + + final List grades; + + @override + Widget build(BuildContext context) { + List gradesCount = List.generate(5, (int index) => grades.where((e) => e.value.value == index + 1).length); + + return Padding( + padding: const EdgeInsets.only(bottom: 6.0, top: 6.0, left: 12.0, right: 6.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: gradesCount.mapIndexed((index, e) => GradesCountItem(count: e, value: index + 1)).toList(), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/grades_count_item.dart b/filcnaplo_mobile_ui/lib/pages/grades/grades_count_item.dart new file mode 100755 index 0000000..011ce09 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/grades_count_item.dart @@ -0,0 +1,33 @@ +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:flutter/material.dart'; + +class GradesCountItem extends StatelessWidget { + const GradesCountItem({Key? key, required this.count, required this.value}) : super(key: key); + + final int count; + final int value; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text.rich( + TextSpan(children: [ + TextSpan( + text: count.toString(), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const TextSpan( + text: "x", + style: TextStyle(fontSize: 13.0), + ), + ]), + style: const TextStyle(fontSize: 15.0), + ), + const SizedBox(width: 5.0), + GradeValueWidget(GradeValue(value, "Value", "Value", 100), size: 19.0, fill: true, shadow: false), + ], + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/grades_page.dart b/filcnaplo_mobile_ui/lib/pages/grades/grades_page.dart new file mode 100755 index 0000000..8cac3c8 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/grades_page.dart @@ -0,0 +1,294 @@ +import 'dart:math'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/models/group_average.dart'; +import 'package:filcnaplo_mobile_ui/common/average_display.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/statistics_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/grade/grade_subject_tile.dart'; +import 'package:filcnaplo_mobile_ui/common/trend_display.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/fail_warning.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grades_count.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/graph.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grade_subject_view.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo/helpers/average_helper.dart'; +import 'package:filcnaplo_premium/ui/mobile/grades/average_selector.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/premium_inline.dart'; +import 'grades_page.i18n.dart'; + +class GradesPage extends StatefulWidget { + const GradesPage({Key? key}) : super(key: key); + + @override + _GradesPageState createState() => _GradesPageState(); +} + +class _GradesPageState extends State { + late UserProvider user; + late GradeProvider gradeProvider; + late UpdateProvider updateProvider; + late String firstName; + late Widget yearlyGraph; + late Widget gradesCount; + List subjectTiles = []; + + int avgDropValue = 0; + + List getSubjectGrades(Subject subject, {int days = 0}) => gradeProvider.grades + .where( + (e) => e.subject == subject && e.type == GradeType.midYear && (days == 0 || e.date.isBefore(DateTime.now().subtract(Duration(days: days))))) + .toList(); + + void generateTiles() { + List subjects = gradeProvider.grades.map((e) => e.subject).toSet().toList()..sort((a, b) => a.name.compareTo(b.name)); + List tiles = []; + + Map subjectAvgs = {}; + + tiles.addAll(subjects.map((subject) { + List subjectGrades = getSubjectGrades(subject); + + double avg = AverageHelper.averageEvals(subjectGrades); + double averageBefore = 0.0; + + if (avgDropValue != 0) { + List gradesBefore = getSubjectGrades(subject, days: avgDropValue); + averageBefore = avgDropValue == 0 ? 0.0 : AverageHelper.averageEvals(gradesBefore); + } + var nullavg = GroupAverage(average: 0.0, subject: subject, uid: "0"); + double groupAverage = gradeProvider.groupAverages.firstWhere((e) => e.subject == subject, orElse: () => nullavg).average; + + if (avg != 0) subjectAvgs[subject] = avg; + + return GradeSubjectTile( + subject, + averageBefore: averageBefore, + average: avg, + groupAverage: avgDropValue == 0 ? groupAverage : 0.0, + onTap: () { + GradeSubjectView(subject, groupAverage: groupAverage).push(context, root: true); + }, + ); + })); + + if (tiles.isNotEmpty) { + tiles.insert(0, yearlyGraph); + tiles.insert(1, gradesCount); + tiles.insert(2, FailWarning(subjectAvgs: subjectAvgs)); + tiles.insert(3, PanelTitle(title: Text(avgDropValue == 0 ? "Subjects".i18n : "Subjects_changes".i18n))); + tiles.insert(4, const PanelHeader(padding: EdgeInsets.only(top: 12.0))); + tiles.add(const PanelFooter(padding: EdgeInsets.only(bottom: 12.0))); + tiles.add(const Padding(padding: EdgeInsets.only(bottom: 24.0))); + } else { + tiles.insert( + 0, + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Empty(subtitle: "empty".i18n), + ), + ); + } + + double subjectAvg = subjectAvgs.isNotEmpty ? subjectAvgs.values.fold(0.0, (double a, double b) => a + b) / subjectAvgs.length : 0.0; + final double classAvg = gradeProvider.groupAverages.isNotEmpty + ? gradeProvider.groupAverages.map((e) => e.average).fold(0.0, (double a, double b) => a + b) / gradeProvider.groupAverages.length + : 0.0; + + if (subjectAvg > 0) { + tiles.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: StatisticsTile( + fill: true, + title: AutoSizeText( + "subjectavg".i18n, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + value: subjectAvg, + ), + ), + const SizedBox(width: 24.0), + Expanded( + child: StatisticsTile( + outline: true, + title: AutoSizeText( + "classavg".i18n, + textAlign: TextAlign.center, + maxLines: 2, + wrapWords: false, + overflow: TextOverflow.ellipsis, + ), + value: classAvg, + ), + ), + ], + )); + } + + tiles.add(Provider.of(context, listen: false).hasPremium + ? const SizedBox() + : const Padding( + padding: EdgeInsets.only(top: 24.0), + child: PremiumInline(features: [ + PremiumInlineFeature.goal, + PremiumInlineFeature.stats, + ]), + )); + + // padding + tiles.add(const SizedBox(height: 32.0)); + + subjectTiles = List.castFrom(tiles); + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + gradeProvider = Provider.of(context); + updateProvider = Provider.of(context); + context.watch(); + + List nameParts = user.displayName?.split(" ") ?? ["?"]; + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + + final double totalClassAvg = gradeProvider.groupAverages.isEmpty + ? 0.0 + : gradeProvider.groupAverages.map((e) => e.average).fold(0.0, (double a, double b) => a + b) / gradeProvider.groupAverages.length; + + final now = gradeProvider.grades.isNotEmpty ? gradeProvider.grades.reduce((v, e) => e.date.isAfter(v.date) ? e : v).date : DateTime.now(); + + final currentStudentAvg = AverageHelper.averageEvals(gradeProvider.grades.where((e) => e.type == GradeType.midYear).toList()); + final prevStudentAvg = AverageHelper.averageEvals(gradeProvider.grades + .where((e) => e.type == GradeType.midYear) + .where((e) => e.date.isBefore(now.subtract(const Duration(days: 30)))) + .toList()); + + List graphGrades = gradeProvider.grades + .where((e) => e.type == GradeType.midYear && (avgDropValue == 0 || e.date.isAfter(DateTime.now().subtract(Duration(days: avgDropValue))))) + .toList(); + + yearlyGraph = Padding( + padding: const EdgeInsets.only(top: 12.0, bottom: 8.0), + child: Panel( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PremiumAverageSelector( + value: avgDropValue, + onChanged: (value) { + setState(() { + avgDropValue = value!; + }); + }, + ), + Row( + children: [ + // if (totalClassAvg >= 1.0) AverageDisplay(average: totalClassAvg, border: true), + // const SizedBox(width: 4.0), + TrendDisplay(previous: prevStudentAvg, current: currentStudentAvg), + if (gradeProvider.grades.where((e) => e.type == GradeType.midYear).isNotEmpty) AverageDisplay(average: currentStudentAvg), + ], + ) + ], + ), + child: Container( + padding: const EdgeInsets.only(top: 12.0, right: 12.0), + child: GradeGraph(graphGrades, dayThreshold: 2, classAvg: totalClassAvg), + ), + ), + ); + + gradesCount = Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Panel(child: GradesCount(grades: graphGrades)), + ); + + generateTiles(); + + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 9.0), + child: NestedScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + SliverAppBar( + centerTitle: false, + pinned: true, + floating: false, + snap: false, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + actions: [ + // Profile Icon + Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ProfileButton( + child: ProfileImage( + heroTag: "profile", + name: firstName, + backgroundColor: ColorUtils.stringToColor(user.displayName ?? "?"), + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + ), + ), + ), + ], + automaticallyImplyLeading: false, + title: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + "Grades".i18n, + style: TextStyle(color: AppColors.of(context).text, fontSize: 32.0, fontWeight: FontWeight.bold), + ), + ), + shadowColor: Theme.of(context).shadowColor, + ), + ], + body: RefreshIndicator( + onRefresh: () => gradeProvider.fetch(), + color: Theme.of(context).colorScheme.secondary, + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemCount: max(subjectTiles.length, 1), + itemBuilder: (context, index) { + if (subjectTiles.isNotEmpty) { + EdgeInsetsGeometry panelPadding = const EdgeInsets.symmetric(horizontal: 24.0); + + if (subjectTiles[index].runtimeType == GradeSubjectTile) { + return Padding( + padding: panelPadding, + child: PanelBody( + child: subjectTiles[index], + padding: const EdgeInsets.symmetric(horizontal: 8.0), + )); + } else { + return Padding(padding: panelPadding, child: subjectTiles[index]); + } + } else { + return Container(); + } + }, + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/grades_page.i18n.dart b/filcnaplo_mobile_ui/lib/pages/grades/grades_page.i18n.dart new file mode 100755 index 0000000..41d0d03 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/grades_page.i18n.dart @@ -0,0 +1,60 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Grades": "Grades", + "Ghost Grades": "Grades", + "Subjects": "Subjects", + "Subjects_changes": "Subject Differences", + "empty": "You don't have any subjects.", + "annual_average": "Annual average", + "3_months_average": "3 Monthly Average", + "30_days_average": "Monthly Average", + "14_days_average": "2 Weekly Average", + "7_days_average": "Weekly Average", + "subjectavg": "Subject Average", + "classavg": "Class Average", + "fail_warning": "Faliure warning", + "fail_warning_description": "You are failing %d subject(s)", + }, + "hu_hu": { + "Grades": "Jegyek", + "Ghost Grades": "Szellem jegyek", + "Subjects": "Tantárgyak", + "Subjects_changes": "Tantárgyi változások", + "empty": "Még nincs egy tárgyad sem.", + "annual_average": "Éves átlag", + "3_months_average": "Háromhavi átlag", + "30_days_average": "Havi átlag", + "14_days_average": "Kétheti átlag", + "7_days_average": "Heti átlag", + "subjectavg": "Tantárgyi átlag", + "classavg": "Osztályátlag", + "fail_warning": "Bukás figyelmeztető", + "fail_warning_description": "Bukásra állsz %d tantárgyból", + }, + "de_de": { + "Grades": "Noten", + "Ghost Grades": "Geist Noten", + "Subjects": "Fächer", + "Subjects_changes": "Betreff Änderungen", + "empty": "Sie haben keine Fächer.", + "annual_average": "Jahresdurchschnitt", + "3_months_average": "Drei-Monats-Durchschnitt", + "30_days_average": "Monatsdurchschnitt", + "14_days_average": "Vierzehntägiger Durchschnitt", + "7_days_average": "Wöchentlicher Durchschnitt", + "subjectavg": "Fächer Durchschnitt", + "classavg": "Klassendurchschnitt", + "fail_warning": "Ausfallwarnung", + "fail_warning_description": "Sie werden in %d des Fachs durchfallen", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/graph.dart b/filcnaplo_mobile_ui/lib/pages/grades/graph.dart new file mode 100755 index 0000000..b93f91a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/graph.dart @@ -0,0 +1,295 @@ +import 'dart:math'; + +import 'package:filcnaplo/helpers/average_helper.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import 'package:i18n_extension/i18n_widget.dart'; +import 'package:provider/provider.dart'; +import 'graph.i18n.dart'; + +class GradeGraph extends StatefulWidget { + const GradeGraph(this.data, {Key? key, this.dayThreshold = 7, this.classAvg}) : super(key: key); + + final List data; + final int dayThreshold; + final double? classAvg; + + @override + _GradeGraphState createState() => _GradeGraphState(); +} + +class _GradeGraphState extends State { + late SettingsProvider settings; + + List getSpots(List data) { + List subjectData = []; + List> sortedData = [[]]; + + // Sort by date descending + data.sort((a, b) => -a.writeDate.compareTo(b.writeDate)); + + // Sort data to points by treshold + for (var element in data) { + if (sortedData.last.isNotEmpty && sortedData.last.last.writeDate.difference(element.writeDate).inDays > widget.dayThreshold) { + sortedData.add([]); + } + for (var dataList in sortedData) { + dataList.add(element); + } + } + + // Create FlSpots from points + for (var dataList in sortedData) { + double average = AverageHelper.averageEvals(dataList); + + if (dataList.isNotEmpty) { + subjectData.add(FlSpot( + dataList[0].writeDate.month + (dataList[0].writeDate.day / 31) + ((dataList[0].writeDate.year - data.last.writeDate.year) * 12), + double.parse(average.toStringAsFixed(2)), + )); + } + } + + return subjectData; + } + + @override + Widget build(BuildContext context) { + settings = Provider.of(context); + + List subjectSpots = []; + List ghostSpots = []; + List extraLinesV = []; + List extraLinesH = []; + + // Filter data + List data = widget.data + .where((e) => e.value.weight != 0) + .where((e) => e.type == GradeType.midYear) + .where((e) => e.gradeType?.name == "Osztalyzat") + .toList(); + + // Filter ghost data + List ghostData = widget.data.where((e) => e.value.weight != 0).where((e) => e.type == GradeType.ghost).toList(); + + // Calculate average + double average = AverageHelper.averageEvals(data); + + // Calculate graph color + Color averageColor = average >= 1 && average <= 5 + ? ColorTween(begin: settings.gradeColors[average.floor() - 1], end: settings.gradeColors[average.ceil() - 1]) + .transform(average - average.floor())! + : Theme.of(context).colorScheme.secondary; + + subjectSpots = getSpots(data); + + // naplo/#73 + if (subjectSpots.isNotEmpty) { + ghostSpots = getSpots(data + ghostData); + + // hax + ghostSpots = ghostSpots.where((e) => e.x >= subjectSpots.map((f) => f.x).reduce(max)).toList(); + ghostSpots = ghostSpots.map((e) => FlSpot(e.x + 0.1, e.y)).toList(); + ghostSpots.add(subjectSpots.firstWhere((e) => e.x >= subjectSpots.map((f) => f.x).reduce(max), orElse: () => const FlSpot(-1, -1))); + ghostSpots.removeWhere((element) => element.x == -1 && element.y == -1); // naplo/#74 + } + + Grade halfYearGrade = widget.data.lastWhere((e) => e.type == GradeType.halfYear, orElse: () => Grade.fromJson({})); + + if (halfYearGrade.date.year != 0 && data.isNotEmpty) { + final maxX = ghostSpots.isNotEmpty ? ghostSpots.first.x : 0; + final x = halfYearGrade.writeDate.month + (halfYearGrade.writeDate.day / 31) + ((halfYearGrade.writeDate.year - data.last.writeDate.year) * 12); + if (x <= maxX) { + extraLinesV.add( + VerticalLine( + x: x, + strokeWidth: 3.0, + color: AppColors.of(context).red.withOpacity(.75), + label: VerticalLineLabel( + labelResolver: (_) => " " + "mid".i18n + " ​", // <- zwsp for padding + show: true, + alignment: Alignment.topLeft, + style: TextStyle( + backgroundColor: Theme.of(context).colorScheme.background, + color: AppColors.of(context).text, + fontSize: 16.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + } + + // Horizontal line displaying the class average + if (widget.classAvg != null && widget.classAvg! > 0.0 && settings.graphClassAvg) { + extraLinesH.add(HorizontalLine( + y: widget.classAvg!, + color: AppColors.of(context).text.withOpacity(.75), + )); + } + + // LineChart is really cute because it tries to render it's contents outside of it's rect. + return widget.data.length <= 2 + ? SizedBox( + height: 150, + child: Center( + child: Text( + "not_enough_grades".i18n, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ) + : ClipRect( + child: SizedBox( + child: subjectSpots.length > 1 + ? Padding( + padding: const EdgeInsets.only(top: 8.0, right: 8.0), + child: LineChart( + LineChartData( + extraLinesData: ExtraLinesData(verticalLines: extraLinesV, horizontalLines: extraLinesH), + lineBarsData: [ + LineChartBarData( + preventCurveOverShooting: true, + spots: subjectSpots, + isCurved: true, + colors: [averageColor], + barWidth: 8, + isStrokeCapRound: true, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + colors: [ + averageColor.withOpacity(0.7), + averageColor.withOpacity(0.3), + averageColor.withOpacity(0.2), + averageColor.withOpacity(0.1), + ], + gradientColorStops: [0.1, 0.6, 0.8, 1], + gradientFrom: const Offset(0, 0), + gradientTo: const Offset(0, 1), + ), + ), + if (ghostData.isNotEmpty && ghostSpots.isNotEmpty) + LineChartBarData( + preventCurveOverShooting: true, + spots: ghostSpots, + isCurved: true, + colors: [AppColors.of(context).text], + barWidth: 8, + isStrokeCapRound: true, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + colors: [ + AppColors.of(context).text.withOpacity(0.7), + AppColors.of(context).text.withOpacity(0.3), + AppColors.of(context).text.withOpacity(0.2), + AppColors.of(context).text.withOpacity(0.1), + ], + gradientColorStops: [0.1, 0.6, 0.8, 1], + gradientFrom: const Offset(0, 0), + gradientTo: const Offset(0, 1), + ), + ), + ], + minY: 1, + maxY: 5, + gridData: FlGridData( + show: true, + horizontalInterval: 1, + // checkToShowVerticalLine: (_) => false, + // getDrawingHorizontalLine: (_) => FlLine( + // color: AppColors.of(context).text.withOpacity(.15), + // strokeWidth: 2, + // ), + // getDrawingVerticalLine: (_) => FlLine( + // color: AppColors.of(context).text.withOpacity(.25), + // strokeWidth: 2, + // ), + ), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: Colors.grey.shade800, + fitInsideVertically: true, + fitInsideHorizontally: true, + ), + handleBuiltInTouches: true, + touchSpotThreshold: 20.0, + getTouchedSpotIndicator: (_, spots) { + return List.generate( + spots.length, + (index) => TouchedSpotIndicatorData( + FlLine( + color: Colors.grey.shade900, + strokeWidth: 3.5, + ), + FlDotData( + getDotPainter: (a, b, c, d) => FlDotCirclePainter( + strokeWidth: 0, + color: Colors.grey.shade900, + radius: 10.0, + ), + ), + ), + ); + }, + ), + borderData: FlBorderData( + show: false, + border: Border.all( + color: Theme.of(context).scaffoldBackgroundColor, + width: 4, + ), + ), + titlesData: FlTitlesData( + bottomTitles: SideTitles( + showTitles: true, + reservedSize: 24, + getTextStyles: (context, value) => TextStyle( + color: AppColors.of(context).text.withOpacity(.75), + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), + margin: 12.0, + getTitles: (value) { + var format = DateFormat("MMM", I18n.of(context).locale.toString()); + + String title = format.format(DateTime(0, value.floor() % 12)).replaceAll(".", ""); + title = title.substring(0, min(title.length, 4)); + + return title.toUpperCase(); + }, + interval: () { + List tData = ghostData.isNotEmpty ? ghostData : data; + tData.sort((a, b) => a.writeDate.compareTo(b.writeDate)); + return tData.first.writeDate.add(const Duration(days: 120)).isBefore(tData.last.writeDate) ? 2.0 : 1.0; + }(), + ), + leftTitles: SideTitles( + showTitles: true, + interval: 1.0, + getTextStyles: (context, value) => TextStyle( + color: AppColors.of(context).text, + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + margin: 16, + ), + rightTitles: SideTitles(showTitles: false), + topTitles: SideTitles(showTitles: false), + ), + ), + ), + ) + : null, + height: 158, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/graph.i18n.dart b/filcnaplo_mobile_ui/lib/pages/grades/graph.i18n.dart new file mode 100755 index 0000000..aa484c9 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/graph.i18n.dart @@ -0,0 +1,24 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "mid": "Mid year", + "not_enough_grades": "Not enough data to show a graph here.", + }, + "hu_hu": { + "mid": "Félév", + "not_enough_grades": "Nem szereztél még elég jegyet grafikon mutatáshoz.", + }, + "de_de": { + "mid": "Halbjährlich", + "not_enough_grades": "Noch nicht genug Noten, um die Grafik zu zeigen.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/grades/subject_grades_container.dart b/filcnaplo_mobile_ui/lib/pages/grades/subject_grades_container.dart new file mode 100755 index 0000000..172ea02 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/grades/subject_grades_container.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class SubjectGradesContainer extends InheritedWidget { + const SubjectGradesContainer({Key? key, required Widget child}) : super(key: key, child: child); + + static SubjectGradesContainer? of(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(SubjectGradesContainer oldWidget) => false; +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/home_page.dart b/filcnaplo_mobile_ui/lib/pages/home/home_page.dart new file mode 100755 index 0000000..ba524ac --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/home_page.dart @@ -0,0 +1,357 @@ +// ignore_for_file: dead_code +import 'dart:math'; + +import 'package:filcnaplo/api/providers/live_card_provider.dart'; +import 'package:filcnaplo/ui/date_widget.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:animated_list_plus/animated_list_plus.dart'; +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo/api/providers/sync.dart'; +import 'package:confetti/confetti.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_kreta_api/providers/absence_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/event_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/exam_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/homework_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/message_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/note_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/api/providers/status_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/filter_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/pages/home/live_card/live_card.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navigation_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'home_page.i18n.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo/ui/filter/widgets.dart'; +import 'package:filcnaplo/ui/filter/sort.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State with TickerProviderStateMixin { + late TabController _tabController; + late UserProvider user; + late SettingsProvider settings; + late UpdateProvider updateProvider; + late StatusProvider statusProvider; + late GradeProvider gradeProvider; + late TimetableProvider timetableProvider; + late MessageProvider messageProvider; + late AbsenceProvider absenceProvider; + late HomeworkProvider homeworkProvider; + late ExamProvider examProvider; + late NoteProvider noteProvider; + late EventProvider eventProvider; + + late PageController _pageController; + ConfettiController? _confettiController; + late LiveCardProvider _liveCard; + late AnimationController _liveCardAnimation; + + late String greeting; + late String firstName; + + late List listOrder; + static const pageCount = 4; + + @override + void initState() { + super.initState(); + + _tabController = TabController(length: pageCount, vsync: this); + _pageController = PageController(); + user = Provider.of(context, listen: false); + _liveCard = Provider.of(context, listen: false); + _liveCardAnimation = AnimationController(vsync: this, duration: const Duration(milliseconds: 500)); + + _liveCardAnimation.animateTo(_liveCard.show ? 1.0 : 0.0, duration: Duration.zero); + + listOrder = List.generate(pageCount, (index) => "$index"); + } + + @override + void dispose() { + // _filterController.dispose(); + _pageController.dispose(); + _tabController.dispose(); + _confettiController?.dispose(); + _liveCardAnimation.dispose(); + + super.dispose(); + } + + void setGreeting() { + DateTime now = DateTime.now(); + if (now.isBefore(DateTime(now.year, DateTime.august, 31)) && now.isAfter(DateTime(now.year, DateTime.june, 14))) { + greeting = "goodrest"; + + if (NavigationScreen.of(context)?.init("confetti") ?? false) { + _confettiController = ConfettiController(duration: const Duration(seconds: 1)); + Future.delayed(const Duration(seconds: 1)).then((value) => mounted ? _confettiController?.play() : null); + } + } else if (now.month == user.student?.birth.month && now.day == user.student?.birth.day) { + greeting = "happybirthday"; + + if (NavigationScreen.of(context)?.init("confetti") ?? false) { + _confettiController = ConfettiController(duration: const Duration(seconds: 3)); + Future.delayed(const Duration(seconds: 1)).then((value) => mounted ? _confettiController?.play() : null); + } + } else if (now.month == DateTime.december && now.day >= 24 && now.day <= 26) { + greeting = "merryxmas"; + } else if (now.month == DateTime.january && now.day == 1) { + greeting = "happynewyear"; + } else if (now.hour >= 18) { + greeting = "goodevening"; + } else if (now.hour >= 10) { + greeting = "goodafternoon"; + } else if (now.hour >= 4) { + greeting = "goodmorning"; + } else { + greeting = "goodevening"; + } + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + settings = Provider.of(context); + statusProvider = Provider.of(context, listen: false); + updateProvider = Provider.of(context); + _liveCard = Provider.of(context); + gradeProvider = Provider.of(context); + context.watch(); + + _liveCardAnimation.animateTo(_liveCard.show ? 1.0 : 0.0); + + setGreeting(); + + List nameParts = user.displayName?.split(" ") ?? ["?"]; + if (!settings.presentationMode) { + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + } else { + firstName = "Béla"; + } + + return Scaffold( + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: NestedScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + AnimatedBuilder( + animation: _liveCardAnimation, + builder: (context, child) { + return SliverAppBar( + automaticallyImplyLeading: false, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + centerTitle: false, + titleSpacing: 0.0, + // Welcome text + title: Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Text( + greeting.i18n.fill([firstName]), + overflow: TextOverflow.fade, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ), + actions: [ + // Profile Icon + Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ProfileButton( + child: ProfileImage( + heroTag: "profile", + name: firstName, + backgroundColor: !settings.presentationMode + ? ColorUtils.stringToColor(user.displayName ?? "?") + : Theme.of(context).colorScheme.secondary, + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + ), + ), + ), + ], + + expandedHeight: _liveCardAnimation.value * 234.0, + + // Live Card + flexibleSpace: FlexibleSpaceBar( + background: Padding( + padding: EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 58.0 + MediaQuery.of(context).padding.top, + bottom: 52.0, + ), + child: Transform.scale( + scale: _liveCardAnimation.value, + child: Opacity( + opacity: _liveCardAnimation.value, + child: const LiveCard(), + ), + ), + ), + ), + shadowColor: Colors.black, + + // Filter Bar + bottom: FilterBar( + items: [ + Tab(text: "All".i18n), + Tab(text: "Grades".i18n), + Tab(text: "Messages".i18n), + Tab(text: "Absences".i18n), + ], + controller: _tabController, + onTap: (i) async { + int selectedPage = _pageController.page!.round(); + + if (i == selectedPage) return; + if (_pageController.page?.roundToDouble() != _pageController.page) { + _pageController.animateToPage(i, curve: Curves.easeIn, duration: kTabScrollDuration); + return; + } + + // swap current page with target page + setState(() { + _pageController.jumpToPage(i); + String currentList = listOrder[selectedPage]; + listOrder[selectedPage] = listOrder[i]; + listOrder[i] = currentList; + }); + }, + ), + pinned: true, + floating: false, + snap: false, + ); + }, + ), + ], + body: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: NotificationListener( + onNotification: (notification) { + // from flutter source + if (notification is ScrollUpdateNotification && !_tabController.indexIsChanging) { + if ((_pageController.page! - _tabController.index).abs() > 1.0) { + _tabController.index = _pageController.page!.floor(); + } + _tabController.offset = (_pageController.page! - _tabController.index).clamp(-1.0, 1.0); + } else if (notification is ScrollEndNotification) { + _tabController.index = _pageController.page!.round(); + if (!_tabController.indexIsChanging) _tabController.offset = (_pageController.page! - _tabController.index).clamp(-1.0, 1.0); + } + return false; + }, + child: PageView.custom( + controller: _pageController, + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return FutureBuilder>( + key: ValueKey(listOrder[index]), + future: getFilterWidgets(homeFilters[index], context: context), + builder: (context, dateWidgets) => dateWidgets.data != null + ? RefreshIndicator( + color: Theme.of(context).colorScheme.secondary, + onRefresh: () => syncAll(context), + child: ImplicitlyAnimatedList( + items: [ + if (index == 0) const SizedBox(key: Key("\$premium")), + ...sortDateWidgets(context, dateWidgets: dateWidgets.data!), + ], + itemBuilder: filterItemBuilder, + spawnIsolate: false, + areItemsTheSame: (a, b) => a.key == b.key, + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + padding: const EdgeInsets.symmetric(horizontal: 24.0), + )) + : Container(), + ); + }, + childCount: 4, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final String data = valueKey.value; + return listOrder.indexOf(data); + }, + ), + physics: const PageScrollPhysics().applyTo(const BouncingScrollPhysics()), + ), + ), + )), + ), + + // confetti 🎊 + if (_confettiController != null) + Align( + alignment: Alignment.bottomCenter, + child: ConfettiWidget( + confettiController: _confettiController!, + blastDirection: -pi / 2, + emissionFrequency: 0.01, + numberOfParticles: 80, + maxBlastForce: 100, + minBlastForce: 90, + gravity: 0.3, + minimumSize: const Size(5, 5), + maximumSize: const Size(20, 20), + ), + ), + ], + ), + ); + } + + Future filterViewBuilder(context, int activeData) async { + final activeFilter = homeFilters[activeData]; + + List filterWidgets = sortDateWidgets( + context, + dateWidgets: await getFilterWidgets(activeFilter, context: context), + showDivider: true, + ); + + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: RefreshIndicator( + color: Theme.of(context).colorScheme.secondary, + onRefresh: () => syncAll(context), + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) { + if (filterWidgets.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: filterWidgets[index], + ); + } else { + return Empty(subtitle: "empty".i18n); + } + }, + itemCount: max(filterWidgets.length, 1), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/home_page.i18n.dart b/filcnaplo_mobile_ui/lib/pages/home/home_page.i18n.dart new file mode 100755 index 0000000..2362b96 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/home_page.i18n.dart @@ -0,0 +1,63 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "goodmorning": "Good morning, %s!", + "goodafternoon": "Good afternoon, %s!", + "goodevening": "Good evening, %s!", + "goodrest": "⛱️ Have a nice holiday, %s!", + "happybirthday": "🎂 Happy birthday, %s!", + "merryxmas": "🎄 Merry Christmas, %s!", + "happynewyear": "🎉 Happy New Year, %s!", + "empty": "Nothing to see here.", + "All": "All", + "Grades": "Grades", + "Messages": "Messages", + "Absences": "Absences", + "update_available": "Update Available", + "missed_exams": "You missed %s exams this week.".one("You missed an exam this week."), + "missed_exam_contact": "Contact %s, to resolve it!", + }, + "hu_hu": { + "goodmorning": "Jó reggelt, %s!", + "goodafternoon": "Szép napot, %s!", + "goodevening": "Szép estét, %s!", + "goodrest": "⛱️ Jó szünetet, %s!", + "happybirthday": "🎂 Boldog születésnapot, %s!", + "merryxmas": "🎄 Boldog Karácsonyt, %s!", + "happynewyear": "🎉 Boldog új évet, %s!", + "empty": "Nincs itt semmi látnivaló.", + "All": "Összes", + "Grades": "Jegyek", + "Messages": "Üzenetek", + "Absences": "Hiányok", + "update_available": "Frissítés elérhető", + "missed_exams": "Ezen a héten hiányoztál %s dolgozatról.".one("Ezen a héten hiányoztál egy dolgozatról."), + "missed_exam_contact": "Keresd %s-t, ha pótolni szeretnéd!", + }, + "de_de": { + "goodmorning": "Guten morgen, %s!", + "goodafternoon": "Guten Tag, %s!", + "goodevening": "Guten Abend, %s!", + "goodrest": "⛱️ Schöne Ferien, %s!", + "happybirthday": "🎂 Alles Gute zum Geburtstag, %s!", + "merryxmas": "🎄 Frohe Weihnachten, %s!", + "happynewyear": "🎉 Frohes neues Jahr, %s!", + "empty": "Hier gibt es nichts zu sehen.", + "All": "Alles", + "Grades": "Noten", + "Messages": "Nachrichten", + "Absences": "Fehlen", + "update_available": "Update verfügbar", + "missed_exams": "Diese Woche haben Sie %s Prüfungen verpasst.".one("Diese Woche haben Sie eine Prüfung verpasst."), + "missed_exam_contact": "Wenden Sie sich an %s, um sie zu erneuern!", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/live_card/heads_up_countdown.dart b/filcnaplo_mobile_ui/lib/pages/home/live_card/heads_up_countdown.dart new file mode 100755 index 0000000..a49a84e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/live_card/heads_up_countdown.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:animated_flip_counter/animated_flip_counter.dart'; +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +class HeadsUpCountdown extends StatefulWidget { + const HeadsUpCountdown({Key? key, required this.maxTime, required this.elapsedTime}) : super(key: key); + + final double maxTime; + final double elapsedTime; + + @override + State createState() => _HeadsUpCountdownState(); +} + +class _HeadsUpCountdownState extends State { + static const _style = TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 70.0, + letterSpacing: -.5, + ); + + late final Timer _timer; + late double elapsed; + + @override + void initState() { + super.initState(); + elapsed = widget.elapsedTime; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (elapsed <= widget.maxTime) elapsed += 1; + setState(() {}); + + if (elapsed >= widget.maxTime) { + Future.delayed(const Duration(seconds: 5), () { + if (mounted) Navigator.of(context).pop(); + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dur = Duration(seconds: (widget.maxTime - elapsed).round()); + return Center( + child: Material( + type: MaterialType.transparency, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedOpacity( + opacity: dur.inSeconds > 0 ? 1.0 : 0.0, + duration: const Duration(milliseconds: 500), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if ((dur.inHours % 24) > 0) ...[ + AnimatedFlipCounter( + value: dur.inHours % 24, + curve: Curves.fastLinearToSlowEaseIn, + textStyle: _style, + ), + const Text(":", style: _style), + ], + AnimatedFlipCounter( + duration: const Duration(seconds: 2), + value: dur.inMinutes % 60, + curve: Curves.fastLinearToSlowEaseIn, + wholeDigits: (dur.inHours % 24) > 0 ? 2 : 1, + textStyle: _style, + ), + const Text(":", style: _style), + AnimatedFlipCounter( + duration: const Duration(seconds: 1), + value: dur.inSeconds % 60, + curve: Curves.fastLinearToSlowEaseIn, + wholeDigits: 2, + textStyle: _style, + ), + ], + ), + ), + if (dur.inSeconds < 0) + AnimatedOpacity( + opacity: dur.inSeconds > 0 ? 0.0 : 1.0, + duration: const Duration(milliseconds: 500), + child: Lottie.asset("assets/animations/bell-alert.json", width: 400), + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.dart b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.dart new file mode 100755 index 0000000..d75385e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.dart @@ -0,0 +1,197 @@ +import 'package:animations/animations.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/pages/home/live_card/heads_up_countdown.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo/api/providers/live_card_provider.dart'; +import 'package:filcnaplo_mobile_ui/pages/home/live_card/live_card_widget.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:i18n_extension/i18n_widget.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'live_card.i18n.dart'; + +class LiveCard extends StatefulWidget { + const LiveCard({Key? key}) : super(key: key); + + @override + _LiveCardState createState() => _LiveCardState(); +} + +class _LiveCardState extends State { + late void Function() listener; + late UserProvider _userProvider; + late LiveCardProvider liveCard; + + @override + void initState() { + super.initState(); + listener = () => setState(() {}); + _userProvider = Provider.of(context, listen: false); + liveCard = Provider.of(context, listen: false); + _userProvider.addListener(liveCard.update); + } + + @override + void dispose() { + _userProvider.removeListener(liveCard.update); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + liveCard = Provider.of(context); + + if (!liveCard.show) return Container(); + + Widget child; + Duration bellDelay = liveCard.delay; + + switch (liveCard.currentState) { + case LiveCardState.morning: + child = LiveCardWidget( + key: const Key('livecard.morning'), + title: DateFormat("EEEE", I18n.of(context).locale.toString()).format(DateTime.now()).capital(), + icon: FeatherIcons.sun, + description: liveCard.nextLesson != null + ? Text.rich( + TextSpan( + children: [ + TextSpan(text: "first_lesson_1".i18n), + TextSpan( + text: liveCard.nextLesson!.subject.renamedTo ?? liveCard.nextLesson!.subject.name.capital(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary.withOpacity(.85), + fontStyle: liveCard.nextLesson!.subject.isRenamed ? FontStyle.italic : null), + ), + TextSpan(text: "first_lesson_2".i18n), + TextSpan( + text: liveCard.nextLesson!.room.capital(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary.withOpacity(.85), + ), + ), + TextSpan(text: "first_lesson_3".i18n), + TextSpan( + text: DateFormat('H:mm').format(liveCard.nextLesson!.start), + style: TextStyle( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary.withOpacity(.85), + ), + ), + TextSpan(text: "first_lesson_4".i18n), + ], + ), + ) + : null, + ); + break; + case LiveCardState.duringLesson: + final elapsedTime = DateTime.now().difference(liveCard.currentLesson!.start).inSeconds.toDouble() + bellDelay.inSeconds; + final maxTime = liveCard.currentLesson!.end.difference(liveCard.currentLesson!.start).inSeconds.toDouble(); + + final showMinutes = maxTime - elapsedTime > 60; + + child = LiveCardWidget( + key: const Key('livecard.duringLesson'), + leading: liveCard.currentLesson!.lessonIndex + (RegExp(r'\d').hasMatch(liveCard.currentLesson!.lessonIndex) ? "." : ""), + title: liveCard.currentLesson!.subject.renamedTo ?? liveCard.currentLesson!.subject.name.capital(), + titleItalic: liveCard.currentLesson!.subject.isRenamed, + subtitle: liveCard.currentLesson!.room, + icon: SubjectIcon.resolveVariant(subject: liveCard.currentLesson!.subject, context: context), + description: liveCard.currentLesson!.description != "" ? Text(liveCard.currentLesson!.description) : null, + nextSubject: liveCard.nextLesson?.subject.renamedTo ?? liveCard.nextLesson?.subject.name.capital(), + nextSubjectItalic: liveCard.nextLesson?.subject.isRenamed ?? false, + nextRoom: liveCard.nextLesson?.room, + progressMax: showMinutes ? maxTime / 60 : maxTime, + progressCurrent: showMinutes ? elapsedTime / 60 : elapsedTime, + progressAccuracy: showMinutes ? ProgressAccuracy.minutes : ProgressAccuracy.seconds, + onProgressTap: () { + showDialog( + barrierColor: Colors.black, + context: context, + builder: (context) => HeadsUpCountdown(maxTime: maxTime, elapsedTime: elapsedTime), + ); + }, + ); + break; + case LiveCardState.duringBreak: + final iconFloorMap = { + "to room": FeatherIcons.chevronsRight, + "up floor": FilcIcons.upstairs, + "down floor": FilcIcons.downstairs, + "ground floor": FilcIcons.downstairs, + }; + + final diff = liveCard.getFloorDifference(); + + final maxTime = liveCard.nextLesson!.start.difference(liveCard.prevLesson!.end).inSeconds.toDouble(); + final elapsedTime = DateTime.now().difference(liveCard.prevLesson!.end).inSeconds.toDouble() + bellDelay.inSeconds.toDouble(); + + final showMinutes = maxTime - elapsedTime > 60; + + child = LiveCardWidget( + key: const Key('livecard.duringBreak'), + title: "break".i18n, + icon: iconFloorMap[diff], + description: liveCard.nextLesson!.room != liveCard.prevLesson!.room + ? Text("go $diff".i18n.fill([diff != "to room" ? (liveCard.nextLesson!.getFloor() ?? 0) : liveCard.nextLesson!.room])) + : Text("stay".i18n), + nextSubject: liveCard.nextLesson?.subject.renamedTo ?? liveCard.nextLesson?.subject.name.capital(), + nextSubjectItalic: liveCard.nextLesson?.subject.isRenamed ?? false, + nextRoom: diff != "to room" ? liveCard.nextLesson?.room : null, + progressMax: showMinutes ? maxTime / 60 : maxTime, + progressCurrent: showMinutes ? elapsedTime / 60 : elapsedTime, + progressAccuracy: showMinutes ? ProgressAccuracy.minutes : ProgressAccuracy.seconds, + onProgressTap: () { + showDialog( + barrierColor: Colors.black, + context: context, + builder: (context) => HeadsUpCountdown( + maxTime: maxTime, + elapsedTime: elapsedTime, + ), + ); + }, + ); + break; + case LiveCardState.afternoon: + child = LiveCardWidget( + key: const Key('livecard.afternoon'), + title: DateFormat("EEEE", I18n.of(context).locale.toString()).format(DateTime.now()).capital(), + icon: FeatherIcons.coffee, + ); + break; + case LiveCardState.night: + child = LiveCardWidget( + key: const Key('livecard.night'), + title: DateFormat("EEEE", I18n.of(context).locale.toString()).format(DateTime.now()).capital(), + icon: FeatherIcons.moon, + ); + break; + default: + child = Container(); + } + + return PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + child: child, + fillColor: Theme.of(context).scaffoldBackgroundColor, + ); + }, + child: child, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.i18n.dart b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.i18n.dart new file mode 100755 index 0000000..2dbca77 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card.i18n.dart @@ -0,0 +1,57 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "next": "Next", + "remaining min": "%d mins".one("%d min"), + "remaining sec": "%d secs".one("%d sec"), + "break": "Break", + "go to room": "Go to room %s.", + "go ground floor": "Go to the ground floor.", + "go up floor": "Go upstairs, to floor %d.", + "go down floor": "Go downstaris, to floor %d.", + "stay": "Stay in this room.", + "first_lesson_1": "Your first lesson will be ", + "first_lesson_2": " in room ", + "first_lesson_3": ", at ", + "first_lesson_4": ".", + }, + "hu_hu": { + "next": "Következő", + "remaining min": "%d perc".one("%d perc"), + "remaining sec": "%d másodperc".one("%d másodperc"), + "break": "Szünet", + "go to room": "Menj a(z) %s terembe.", + "go ground floor": "Menj a földszintre.", + "go up floor": "Menj fel a(z) %d. emeletre.", + "go down floor": "Menj le a(z) %d. emeletre.", + "stay": "Maradj ebben a teremben.", + "first_lesson_1": "Az első órád ", + "first_lesson_2": " lesz, a ", + "first_lesson_3": " teremben, ", + "first_lesson_4": "-kor.", + }, + "de_de": { + "next": "Nächste", + "remaining min": "%d Minuten".one("%d Minute"), + "remaining sec": "%d Sekunden".one("%d Sekunden"), + "break": "Pause", + "go to room": "Geh in den Raum %s.", + "go ground floor": "Geh dir Treppe hinunter.", + "go up floor": "Geh in die %d. Stock hinauf.", + "go down floor": "Geh runter in den %d. Stock.", + "stay": "Im Zimmer bleiben.", + "first_lesson_1": "Ihre erste Stunde ist ", + "first_lesson_2": ", in Raum ", + "first_lesson_3": ", um ", + "first_lesson_4": " Uhr.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card_widget.dart b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card_widget.dart new file mode 100755 index 0000000..b4bdf2e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/live_card/live_card_widget.dart @@ -0,0 +1,247 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/progress_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'live_card.i18n.dart'; + +enum ProgressAccuracy { minutes, seconds } + +class LiveCardWidget extends StatefulWidget { + const LiveCardWidget({ + Key? key, + this.leading, + this.title, + this.titleItalic = false, + this.subtitle, + this.icon, + this.description, + this.nextRoom, + this.nextSubject, + this.nextSubjectItalic = false, + this.progressCurrent, + this.progressMax, + this.progressAccuracy = ProgressAccuracy.minutes, + this.onProgressTap, + }) : super(key: key); + + final String? leading; + final String? title; + final bool titleItalic; + final String? subtitle; + final IconData? icon; + final Widget? description; + final String? nextSubject; + final bool nextSubjectItalic; + final String? nextRoom; + final double? progressCurrent; + final double? progressMax; + final ProgressAccuracy? progressAccuracy; + final Function()? onProgressTap; + + @override + State createState() => _LiveCardWidgetState(); +} + +class _LiveCardWidgetState extends State { + bool hold = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPressDown: (_) => setState(() => hold = true), + onLongPressEnd: (_) => setState(() => hold = false), + onLongPressCancel: () => setState(() => hold = false), + child: AnimatedScale( + scale: hold ? 1.03 : 1.0, + curve: Curves.easeInOutBack, + duration: const Duration(milliseconds: 300), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 2.0), + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(16.0), + boxShadow: [ + BoxShadow( + offset: const Offset(0, 21), + blurRadius: 23.0, + color: Theme.of(context).shadowColor, + ) + ], + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: OverflowBox( + maxHeight: 96.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.leading != null) + Padding( + padding: const EdgeInsets.only(right: 12.0, top: 8.0), + child: Text( + widget.leading!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32.0, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + children: [ + if (widget.title != null) + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: widget.title!, style: TextStyle(fontStyle: widget.titleItalic ? FontStyle.italic : null)), + if (widget.subtitle != null) + WidgetSpan( + child: Container( + margin: const EdgeInsets.only(left: 6.0, bottom: 3.0), + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(.3), + borderRadius: BorderRadius.circular(4.0), + ), + child: Text( + widget.subtitle!, + style: TextStyle( + height: 1.2, + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ], + ), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 22.0), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.title != null) const SizedBox(width: 6.0), + if (widget.icon != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Icon( + widget.icon, + size: 26.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + ), + ], + ), + if (widget.description != null) + DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 16.0, + height: 1.0, + color: AppColors.of(context).text.withOpacity(.75), + ), + maxLines: !(widget.nextSubject == null && widget.progressCurrent == null && widget.progressMax == null) ? 1 : 2, + softWrap: false, + overflow: TextOverflow.ellipsis, + child: widget.description!, + ), + ], + ), + ), + ], + ), + ), + if (!(widget.nextSubject == null && widget.progressCurrent == null && widget.progressMax == null)) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + if (widget.nextSubject != null) const Icon(FeatherIcons.arrowRight, size: 12.0), + if (widget.nextSubject != null) const SizedBox(width: 4.0), + if (widget.nextSubject != null) + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: widget.nextSubject!, style: TextStyle(fontStyle: widget.nextSubjectItalic ? FontStyle.italic : null)), + if (widget.nextRoom != null) + WidgetSpan( + child: Container( + margin: const EdgeInsets.only(left: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 3.0, vertical: 1.5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(.25), + borderRadius: BorderRadius.circular(4.0), + ), + child: Text( + widget.nextRoom!, + style: TextStyle( + height: 1.1, + fontSize: 11.0, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.secondary.withOpacity(.9), + ), + ), + ), + ), + ], + ), + style: TextStyle( + color: AppColors.of(context).text.withOpacity(.8), + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + ), + ), + if (widget.nextRoom == null && widget.nextSubject == null) const Spacer(), + if (widget.progressCurrent != null && widget.progressMax != null) + GestureDetector( + onTap: widget.onProgressTap, + child: Container( + color: Colors.transparent, + child: Text( + "remaining ${widget.progressAccuracy == ProgressAccuracy.minutes ? 'min' : 'sec'}" + .plural((widget.progressMax! - widget.progressCurrent!).round()), + maxLines: 1, + style: TextStyle( + fontWeight: FontWeight.w500, + color: AppColors.of(context).text.withOpacity(.75), + ), + ), + ), + ) + ], + ), + ), + if (widget.progressCurrent != null && widget.progressMax != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: ProgressBar(value: widget.progressCurrent! / widget.progressMax!), + ) + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/home/particle.dart b/filcnaplo_mobile_ui/lib/pages/home/particle.dart new file mode 100755 index 0000000..5c47a5d --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/home/particle.dart @@ -0,0 +1,438 @@ +// MIT License + +// Copyright (c) 2018 Norbert Kozsir + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:math'; +import 'package:flutter/material.dart'; + +typedef ParticleBuilder = Particle Function(int index); + +abstract class Particle { + void paint(Canvas canvas, Size size, double progress, int seed); +} + +class FourRandomSlotParticle extends Particle { + final List children; + + final double relativeDistanceToMiddle; + + FourRandomSlotParticle({required this.children, this.relativeDistanceToMiddle = 2.0}); + + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + Random random = Random(seed); + int side = 0; + for (Particle particle in children) { + PositionedParticle( + position: sideToOffset(side, size, random) * relativeDistanceToMiddle, + child: particle, + ).paint(canvas, size, progress, seed); + side++; + } + } + + Offset sideToOffset(int side, Size size, Random random) { + if (side == 0) { + return Offset(-random.nextDouble() * (size.width / 2), -random.nextDouble() * (size.height / 2)); + } else if (side == 1) { + return Offset(random.nextDouble() * (size.width / 2), -random.nextDouble() * (size.height / 2)); + } else if (side == 2) { + return Offset(random.nextDouble() * (size.width / 2), random.nextDouble() * (size.height / 2)); + } else if (side == 3) { + return Offset(-random.nextDouble() * (size.width / 2), random.nextDouble() * (size.height / 2)); + } else { + throw Exception(); + } + } + + double randomOffset(Random random, int range) { + return range / 2 - random.nextInt(range); + } +} + +class PoppingCircle extends Particle { + final Color color; + + PoppingCircle({required this.color}); + + final double radius = 3.0; + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + if (progress < 0.5) { + canvas.drawCircle( + Offset.zero, + radius + (progress * 8), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 5.0 - progress * 2); + } else { + CircleMirror( + numberOfParticles: 4, + child: AnimatedPositionedParticle( + begin: const Offset(0.0, 5.0), + end: const Offset(0.0, 15.0), + child: FadingRect( + color: color, + height: 7.0, + width: 2.0, + )), + initialRotation: pi / 4, + ).paint(canvas, size, progress, seed); + } + } +} + +class Firework extends Particle { + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + FourRandomSlotParticle(children: [ + IntervalParticle( + interval: const Interval(0.0, 0.5, curve: Curves.easeIn), + child: PoppingCircle( + color: Colors.deepOrangeAccent, + ), + ), + IntervalParticle( + interval: const Interval(0.2, 0.5, curve: Curves.easeIn), + child: PoppingCircle( + color: Colors.green, + ), + ), + IntervalParticle( + interval: const Interval(0.4, 0.8, curve: Curves.easeIn), + child: PoppingCircle( + color: Colors.indigo, + ), + ), + IntervalParticle( + interval: const Interval(0.5, 1.0, curve: Curves.easeIn), + child: PoppingCircle( + color: Colors.teal, + ), + ), + ]).paint(canvas, size, progress, seed); + } +} + +/// Mirrors a given particle around a circle. +/// +/// When using the default constructor you specify one [Particle], this particle +/// is going to be used on its own, this implies that +/// all mirrored particles are identical (expect for the rotation around the circle) +class CircleMirror extends Particle { + final ParticleBuilder particleBuilder; + + final double initialRotation; + + final int numberOfParticles; + + CircleMirror.builder({required this.particleBuilder, required this.initialRotation, required this.numberOfParticles}); + + CircleMirror({required Particle child, required this.initialRotation, required this.numberOfParticles}) : particleBuilder = ((index) => child); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.save(); + canvas.rotate(initialRotation); + for (int i = 0; i < numberOfParticles; i++) { + particleBuilder(i).paint(canvas, size, progress, seed); + canvas.rotate(pi / (numberOfParticles / 2)); + } + canvas.restore(); + } +} + +/// Mirrors a given particle around a circle. +/// +/// When using the default constructor you specify one [Particle], this particle +/// is going to be used on its own, this implies that +/// all mirrored particles are identical (expect for the rotation around the circle) +class RectangleMirror extends Particle { + final ParticleBuilder particleBuilder; + + /// Position of the first particle on the rect + final double initialDistance; + + final int numberOfParticles; + + RectangleMirror.builder({required this.particleBuilder, required this.initialDistance, required this.numberOfParticles}); + + RectangleMirror({required Particle child, required this.initialDistance, required this.numberOfParticles}) + : particleBuilder = ((index) => child); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.save(); + double totalLength = size.width * 2 + size.height * 2; + double distanceBetweenParticles = totalLength / numberOfParticles; + + bool onHorizontalAxis = true; + int side = 0; + + assert((distanceBetweenParticles * numberOfParticles).round() == totalLength.round()); + + canvas.translate(-size.width / 2, -size.height / 2); + + double currentDistance = initialDistance; + for (int i = 0; i < numberOfParticles; i++) { + while (true) { + if (onHorizontalAxis ? currentDistance > size.width : currentDistance > size.height) { + currentDistance -= onHorizontalAxis ? size.width : size.height; + onHorizontalAxis = !onHorizontalAxis; + side = (++side) % 4; + } else { + if (side == 0) { + assert(onHorizontalAxis); + moveTo(canvas, size, 0, currentDistance, 0.0, () { + particleBuilder(i).paint(canvas, size, progress, seed); + }); + } else if (side == 1) { + assert(!onHorizontalAxis); + moveTo(canvas, size, 1, size.width, currentDistance, () { + particleBuilder(i).paint(canvas, size, progress, seed); + }); + } else if (side == 2) { + assert(onHorizontalAxis); + moveTo(canvas, size, 2, size.width - currentDistance, size.height, () { + particleBuilder(i).paint(canvas, size, progress, seed); + }); + } else if (side == 3) { + assert(!onHorizontalAxis); + moveTo(canvas, size, 3, 0.0, size.height - currentDistance, () { + particleBuilder(i).paint(canvas, size, progress, seed); + }); + } + break; + } + } + currentDistance += distanceBetweenParticles; + } + + canvas.restore(); + } + + void moveTo(Canvas canvas, Size size, int side, double x, double y, VoidCallback painter) { + canvas.save(); + canvas.translate(x, y); + canvas.rotate(-atan2(size.width / 2 - x, size.height / 2 - y)); + painter(); + canvas.restore(); + } +} + +/// Offsets a child by a given [Offset] +class PositionedParticle extends Particle { + PositionedParticle({required this.position, required this.child}); + + final Particle child; + + final Offset position; + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.save(); + canvas.translate(position.dx, position.dy); + child.paint(canvas, size, progress, seed); + canvas.restore(); + } +} + +/// Animates a childs position based on a Tween +class AnimatedPositionedParticle extends Particle { + AnimatedPositionedParticle({required Offset begin, required Offset end, required this.child}) : offsetTween = Tween(begin: begin, end: end); + + final Particle child; + + final Tween offsetTween; + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.save(); + canvas.translate(offsetTween.lerp(progress).dx, offsetTween.lerp(progress).dy); + child.paint(canvas, size, progress, seed); + canvas.restore(); + } +} + +/// Specifies an [Interval] for its child. +/// +/// Instead of applying a curve the the input parameters of the paint method, +/// apply it with this Particle. +/// +/// If you want you child to only animate from 0.0 - 0.5 (relative), specify an [Interval] with those values. +class IntervalParticle extends Particle { + final Interval interval; + + final Particle child; + + IntervalParticle({required this.child, required this.interval}); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + if (progress < interval.begin || progress > interval.end) return; + child.paint(canvas, size, interval.transform(progress), seed); + } +} + +/// Does nothing else than holding a list of particles and painting them in that order +class CompositeParticle extends Particle { + final List children; + + CompositeParticle({required this.children}); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + for (Particle particle in children) { + particle.paint(canvas, size, progress, seed); + } + } +} + +/// A particle which rotates the child. +/// +/// Does not animate. +class RotationParticle extends Particle { + final Particle child; + + final double rotation; + + RotationParticle({required this.child, required this.rotation}); + + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + canvas.save(); + canvas.rotate(rotation); + child.paint(canvas, size, progress, seed); + canvas.restore(); + } +} + +/// A particle which rotates a child along a given [Tween] +class AnimatedRotationParticle extends Particle { + final Particle child; + + final Tween rotation; + + AnimatedRotationParticle({required this.child, required double begin, required double end}) : rotation = Tween(begin: begin, end: end); + + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + canvas.save(); + canvas.rotate(rotation.lerp(progress)); + child.paint(canvas, size, progress, seed); + canvas.restore(); + } +} + +/// Geometry +/// +/// These are some basic geometric classes which also fade out as time goes on. +/// Each primitive should draw itself at the origin. If the orientation matters it should be directed to the top +/// (negative y) +/// +/// A rectangle which also fades out over time. +class FadingRect extends Particle { + final Color color; + final double width; + final double height; + + FadingRect({required this.color, required this.width, required this.height}); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.drawRect(Rect.fromLTWH(0.0, 0.0, width, height), Paint()..color = color.withOpacity(1 - progress)); + } +} + +/// A circle which fades out over time +class FadingCircle extends Particle { + final Color color; + final double radius; + + FadingCircle({required this.color, required this.radius}); + + @override + void paint(Canvas canvas, Size size, double progress, seed) { + canvas.drawCircle(Offset.zero, radius, Paint()..color = color.withOpacity(1 - progress)); + } +} + +/// A triangle which also fades out over time +class FadingTriangle extends Particle { + /// This controls the shape of the triangle. + /// + /// Value between 0 and 1 + final double variation; + + final Color color; + + /// The size of the base side of the triangle. + final double baseSize; + + /// This is the factor of how much bigger then length than the width is + final double heightToBaseFactor; + + FadingTriangle({required this.variation, required this.color, required this.baseSize, required this.heightToBaseFactor}); + + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + Path path = Path(); + path.moveTo(0.0, 0.0); + path.lineTo(baseSize * variation, baseSize * heightToBaseFactor); + path.lineTo(baseSize, 0.0); + path.close(); + canvas.drawPath(path, Paint()..color = color.withOpacity(1 - progress)); + } +} + +/// An ugly looking "snake" +/// +/// See for yourself +class FadingSnake extends Particle { + final double width; + final double segmentLength; + final int segments; + final double curvyness; + + final Color color; + + FadingSnake({required this.width, required this.segmentLength, required this.segments, required this.curvyness, required this.color}); + + @override + void paint(Canvas canvas, Size size, double progress, int seed) { + canvas.save(); + canvas.rotate(pi / 6); + Path path = Path(); + for (int i = 0; i < segments; i++) { + path.quadraticBezierTo(curvyness * i, segmentLength * (i + 1), curvyness * (i + 1), segmentLength * (i + 1)); + } + for (int i = segments - 1; i >= 0; i--) { + path.quadraticBezierTo(curvyness * (i + 1), segmentLength * i - curvyness, curvyness * i, segmentLength * i - curvyness); + } + path.close(); + canvas.drawPath(path, Paint()..color = color); + canvas.restore(); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/messages/messages_page.dart b/filcnaplo_mobile_ui/lib/pages/messages/messages_page.dart new file mode 100755 index 0000000..8f26275 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/messages/messages_page.dart @@ -0,0 +1,179 @@ +import 'dart:math'; + +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo/ui/date_widget.dart'; +import 'package:filcnaplo_kreta_api/providers/message_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/message.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/filter_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo/ui/filter/sort.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/message/message_viewable.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'messages_page.i18n.dart'; + +class MessagesPage extends StatefulWidget { + const MessagesPage({Key? key}) : super(key: key); + + @override + _MessagesPageState createState() => _MessagesPageState(); +} + +class _MessagesPageState extends State with TickerProviderStateMixin { + late UserProvider user; + late MessageProvider messageProvider; + late UpdateProvider updateProvider; + late String firstName; + late TabController tabController; + + @override + void initState() { + super.initState(); + + tabController = TabController(length: 4, vsync: this); + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + messageProvider = Provider.of(context); + updateProvider = Provider.of(context); + + List nameParts = user.displayName?.split(" ") ?? ["?"]; + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: NestedScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + SliverAppBar( + pinned: true, + floating: false, + snap: false, + centerTitle: false, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + actions: [ + // Profile Icon + Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ProfileButton( + child: ProfileImage( + heroTag: "profile", + name: firstName, + backgroundColor: ColorUtils.stringToColor(user.displayName ?? "?"), + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + ), + ), + ), + ], + automaticallyImplyLeading: false, + shadowColor: Theme.of(context).shadowColor, + title: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + "Messages".i18n, + style: TextStyle(color: AppColors.of(context).text, fontSize: 32.0, fontWeight: FontWeight.bold), + ), + ), + bottom: FilterBar(items: [ + Tab(text: "Inbox".i18n), + Tab(text: "Sent".i18n), + Tab(text: "Trash".i18n), + Tab(text: "Draft".i18n), + ], controller: tabController), + ), + ], + body: TabBarView( + physics: const BouncingScrollPhysics(), + controller: tabController, + children: List.generate(4, (index) => filterViewBuilder(context, index))), + ), + ), + ); + } + + List getFilterWidgets(MessageType activeData) { + List items = []; + switch (activeData) { + case MessageType.inbox: + for (var message in messageProvider.messages) { + if (message.type == MessageType.inbox) { + items.add(DateWidget( + date: message.date, + widget: MessageViewable(message), + )); + } + } + break; + case MessageType.sent: + for (var message in messageProvider.messages) { + if (message.type == MessageType.sent) { + items.add(DateWidget( + date: message.date, + widget: MessageViewable(message), + )); + } + } + break; + case MessageType.trash: + for (var message in messageProvider.messages) { + if (message.type == MessageType.trash) { + items.add(DateWidget( + date: message.date, + widget: MessageViewable(message), + )); + } + } + break; + case MessageType.draft: + for (var message in messageProvider.messages) { + if (message.type == MessageType.draft) { + items.add(DateWidget( + date: message.date, + widget: MessageViewable(message), + )); + } + } + break; + } + return items; + } + + Widget filterViewBuilder(context, int activeData) { + List filterWidgets = sortDateWidgets(context, dateWidgets: getFilterWidgets(MessageType.values[activeData]), hasShadow: true); + + return Padding( + padding: const EdgeInsets.only(top: 12.0), + child: RefreshIndicator( + color: Theme.of(context).colorScheme.secondary, + onRefresh: () { + return Future.wait([ + messageProvider.fetch(type: MessageType.inbox), + messageProvider.fetch(type: MessageType.sent), + messageProvider.fetch(type: MessageType.trash), + ]); + }, + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemBuilder: (context, index) => filterWidgets.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0), + child: filterWidgets[index], + ) + : Empty(subtitle: "empty".i18n), + itemCount: max(filterWidgets.length, 1), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/messages/messages_page.i18n.dart b/filcnaplo_mobile_ui/lib/pages/messages/messages_page.i18n.dart new file mode 100755 index 0000000..b1665e1 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/messages/messages_page.i18n.dart @@ -0,0 +1,36 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Messages": "Messages", + "Inbox": "Inbox", + "Sent": "Sent", + "Trash": "Trash", + "Draft": "Draft", + "empty": "You have no messages.", + }, + "hu_hu": { + "Messages": "Üzenetek", + "Inbox": "Beérkezett", + "Sent": "Elküldött", + "Trash": "Kuka", + "Draft": "Piszkozat", + "empty": "Nincsenek üzeneteid.", + }, + "de_de": { + "Messages": "Nachrichten", + "Inbox": "Posteingang", + "Sent": "Gesendet", + "Trash": "Müll", + "Draft": "Entwurf", + "empty": "Sie haben keine Nachrichten.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/pages/timetable/day_title.dart b/filcnaplo_mobile_ui/lib/pages/timetable/day_title.dart new file mode 100755 index 0000000..0d63084 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/timetable/day_title.dart @@ -0,0 +1,62 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/utils/format.dart'; + +class DayTitle extends StatefulWidget { + const DayTitle({Key? key, required this.dayTitle, required this.controller}) : super(key: key); + + final String Function(int) dayTitle; + final TabController controller; + + @override + State createState() => _DayTitleState(); +} + +class _DayTitleState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(listener); + } + + void listener() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + widget.controller.removeListener(listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + double width = MediaQuery.of(context).size.width; + + return AnimatedBuilder( + animation: widget.controller.animation!, + builder: (context, _) { + double value = widget.controller.animation!.value; + + return Transform.translate( + offset: Offset(-value * width / 1.5, 0), + child: Row( + children: List.generate( + widget.controller.length, + (index) { + double opacity = (value - index + 1).clamp(0, 1); + + return SizedBox( + width: MediaQuery.of(context).size.width / 1.5, + child: Text( + widget.dayTitle(index).capital(), + style: TextStyle(color: AppColors.of(context).text.withOpacity(opacity), fontSize: 32.0, fontWeight: FontWeight.bold), + ), + ); + }, + ), + ), + ); + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.dart b/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.dart new file mode 100755 index 0000000..e44cbc9 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.dart @@ -0,0 +1,472 @@ +import 'dart:math'; +import 'package:animations/animations.dart'; +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo_kreta_api/client/client.dart'; +import 'package:filcnaplo_kreta_api/models/week.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/models/lesson.dart'; +import 'package:filcnaplo_mobile_ui/common/dot.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/lesson/lesson_view.dart'; +import 'package:filcnaplo_kreta_api/controllers/timetable_controller.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/lesson/lesson_viewable.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/day_title.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navigation_route_handler.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navigation_screen.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:intl/intl.dart'; +import 'package:i18n_extension/i18n_widget.dart'; +import 'package:filcnaplo_premium/ui/mobile/timetable/fs_timetable_button.dart'; +import 'timetable_page.i18n.dart'; + +// todo: "fix" overflow (priority: -1) + +class TimetablePage extends StatefulWidget { + const TimetablePage({Key? key, this.initialDay, this.initialWeek}) : super(key: key); + + final DateTime? initialDay; + final Week? initialWeek; + + static void jump(BuildContext context, {Week? week, DateTime? day, Lesson? lesson}) { + // Go to timetable page with arguments + NavigationScreen.of(context)?.customRoute(navigationPageRoute((context) => TimetablePage( + initialDay: lesson?.date ?? day, + initialWeek: lesson?.date != null + ? Week.fromDate(lesson!.date) + : day != null + ? Week.fromDate(day) + : week, + ))); + + NavigationScreen.of(context)?.setPage("timetable"); + + // Show initial Lesson + if (lesson != null) LessonView.show(lesson, context: context); + } + + @override + _TimetablePageState createState() => _TimetablePageState(); +} + +class _TimetablePageState extends State with TickerProviderStateMixin, WidgetsBindingObserver { + late UserProvider user; + late TimetableProvider timetableProvider; + late UpdateProvider updateProvider; + late String firstName; + late TimetableController _controller; + late TabController _tabController; + late Widget empty; + + int _getDayIndex(DateTime date) { + int index = 0; + if (_controller.days == null || (_controller.days?.isEmpty ?? true)) return index; + + // find the first day with upcoming lessons + index = _controller.days!.indexWhere((day) => day.last.end.isAfter(date)); + if (index == -1) index = 0; // fallback + + return index; + } + + // Update timetable on user change + Future _userListener() async { + await Provider.of(context, listen: false).refreshLogin(); + if (mounted) _controller.jump(_controller.currentWeek, context: context); + } + + // When the app comes to foreground, refresh the timetable + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (mounted) _controller.jump(_controller.currentWeek, context: context); + } + } + + @override + void initState() { + super.initState(); + + // Initalize controllers + _controller = TimetableController(); + _tabController = TabController(length: 0, vsync: this, initialIndex: 0); + + empty = Empty(subtitle: "empty".i18n); + + bool initial = true; + + // Only update the TabController on week changes + _controller.addListener(() { + if (_controller.days == null) return; + setState(() { + _tabController = TabController( + length: _controller.days!.length, + vsync: this, + initialIndex: min(_tabController.index, max(_controller.days!.length - 1, 0)), + ); + + if (initial || _controller.previousWeekId != _controller.currentWeekId) { + _tabController.animateTo(_getDayIndex(widget.initialDay ?? DateTime.now())); + } + initial = false; + + // Empty is updated once every week change + empty = Empty(subtitle: "empty".i18n); + }); + }); + + if (mounted) { + if (widget.initialWeek != null) { + _controller.jump(widget.initialWeek!, context: context, initial: true); + } else { + _controller.jump(_controller.currentWeek, context: context, initial: true, skip: true); + } + } + + // Listen for user changes + user = Provider.of(context, listen: false); + user.addListener(_userListener); + + // Register listening for app state changes to refresh the timetable + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + _tabController.dispose(); + _controller.dispose(); + user.removeListener(_userListener); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + String dayTitle(int index) { + // Sometimes when changing weeks really fast, + // controller.days might be null or won't include index + try { + return DateFormat("EEEE", I18n.of(context).locale.languageCode).format(_controller.days![index].first.date); + } catch (e) { + return "timetable".i18n; + } + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + timetableProvider = Provider.of(context); + updateProvider = Provider.of(context); + + // First name + List nameParts = user.displayName?.split(" ") ?? ["?"]; + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 9.0), + child: RefreshIndicator( + onRefresh: () => mounted ? _controller.jump(_controller.currentWeek, context: context, loader: false) : Future.value(null), + color: Theme.of(context).colorScheme.secondary, + edgeOffset: 132.0, + child: NestedScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + headerSliverBuilder: (context, _) => [ + SliverAppBar( + centerTitle: false, + pinned: true, + floating: false, + snap: false, + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + actions: [ + PremiumFSTimetableButton(controller: _controller), + + // Profile Icon + Padding( + padding: const EdgeInsets.only(right: 24.0), + child: ProfileButton( + child: ProfileImage( + heroTag: "profile", + name: firstName, + backgroundColor: ColorUtils.stringToColor(user.displayName ?? "?"), + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + ), + ), + ), + ], + automaticallyImplyLeading: false, + // Current day text + title: PageTransitionSwitcher( + reverse: _controller.currentWeekId < _controller.previousWeekId, + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + child: child, + fillColor: Theme.of(context).scaffoldBackgroundColor, + ); + }, + layoutBuilder: (List entries) { + return Stack( + children: entries, + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Row( + children: [ + () { + final show = + _controller.days == null || (_controller.loadType != LoadType.offline && _controller.loadType != LoadType.online); + const duration = Duration(milliseconds: 150); + return AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: duration, + curve: Curves.easeInOut, + child: AnimatedContainer( + duration: duration, + width: show ? 24.0 : 0.0, + curve: Curves.easeInOut, + child: const Padding( + padding: EdgeInsets.only(right: 12.0), + child: CupertinoActivityIndicator(), + ), + ), + ); + }(), + () { + if ((_controller.days?.length ?? 0) > 0) { + return DayTitle(controller: _tabController, dayTitle: dayTitle); + } else { + return Text( + "timetable".i18n, + style: TextStyle( + fontSize: 32.0, + fontWeight: FontWeight.bold, + color: AppColors.of(context).text, + ), + ); + } + }(), + ], + ), + ), + ), + shadowColor: Theme.of(context).shadowColor, + bottom: PreferredSize( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Previous week + IconButton( + onPressed: _controller.currentWeekId == 0 + ? null + : () => setState(() { + _controller.previous(context); + }), + splashRadius: 24.0, + icon: const Icon(FeatherIcons.chevronLeft), + color: Theme.of(context).colorScheme.secondary), + + // Week selector + InkWell( + borderRadius: BorderRadius.circular(6.0), + onTap: () => setState(() { + _controller.current(); + if (mounted) { + _controller.jump( + _controller.currentWeek, + context: context, + loader: _controller.currentWeekId != _controller.previousWeekId, + ); + } + _tabController.animateTo(_getDayIndex(DateTime.now())); + }), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "${_controller.currentWeekId + 1}. " + + "week".i18n + + " (" + + // Week start + DateFormat((_controller.currentWeek.start.year != DateTime.now().year ? "yy. " : "") + "MMM d.", + I18n.of(context).locale.languageCode) + .format(_controller.currentWeek.start) + + " - " + + // Week end + DateFormat((_controller.currentWeek.start.year != DateTime.now().year ? "yy. " : "") + "MMM d.", + I18n.of(context).locale.languageCode) + .format(_controller.currentWeek.end) + + ")", + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14.0, + ), + ), + ), + ), + + // Next week + IconButton( + onPressed: _controller.currentWeekId == 51 + ? null + : () => setState(() { + _controller.next(context); + }), + splashRadius: 24.0, + icon: const Icon(FeatherIcons.chevronRight), + color: Theme.of(context).colorScheme.secondary), + ], + ), + ), + preferredSize: const Size.fromHeight(50.0), + ), + ), + ], + body: PageTransitionSwitcher( + transitionBuilder: ( + Widget child, + Animation primaryAnimation, + Animation secondaryAnimation, + ) { + return FadeThroughTransition( + child: child, + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + fillColor: Theme.of(context).scaffoldBackgroundColor, + ); + }, + child: _controller.days != null + ? Column( + key: Key(_controller.currentWeek.toString()), + children: [ + // Week view + _tabController.length > 0 + ? Expanded( + child: TabBarView( + physics: const BouncingScrollPhysics(), + controller: _tabController, + // days + children: List.generate( + _controller.days!.length, + (tab) => RefreshIndicator( + onRefresh: () => + mounted ? _controller.jump(_controller.currentWeek, context: context, loader: false) : Future.value(null), + color: Theme.of(context).colorScheme.secondary, + child: ListView.builder( + padding: EdgeInsets.zero, + physics: const BouncingScrollPhysics(), + itemCount: _controller.days![tab].length + 2, + itemBuilder: (context, index) { + if (_controller.days == null) return Container(); + + // Header + if (index == 0) { + return const Padding( + padding: EdgeInsets.only(top: 8.0, left: 24.0, right: 24.0), + child: PanelHeader(padding: EdgeInsets.only(top: 12.0)), + ); + } + + // Footer + if (index == _controller.days![tab].length + 1) { + return const Padding( + padding: EdgeInsets.only(bottom: 8.0, left: 24.0, right: 24.0), + child: PanelFooter(padding: EdgeInsets.only(top: 12.0)), + ); + } + + // Body + final Lesson lesson = _controller.days![tab][index - 1]; + final bool swapDescDay = _controller.days![tab].map((l) => l.swapDesc ? 1 : 0).reduce((a, b) => a + b) >= + _controller.days![tab].length * .5; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: PanelBody( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: LessonViewable( + lesson, + swapDesc: swapDescDay, + ), + ), + ); + }, + ), + ), + ), + ), + ) + + // Empty week + : Expanded( + child: Center(child: empty), + ), + + // Day selector + TabBar( + dividerColor: Colors.transparent, + controller: _tabController, + // Label + labelPadding: EdgeInsets.zero, + labelColor: Theme.of(context).colorScheme.secondary, + unselectedLabelColor: AppColors.of(context).text.withOpacity(0.9), + // Indicator + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: EdgeInsets.zero, + indicator: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(0.25), + borderRadius: BorderRadius.circular(45.0), + ), + overlayColor: MaterialStateProperty.all(const Color(0x00000000)), + // Tabs + padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0), + tabs: List.generate(_tabController.length, (index) { + String label = DateFormat("E", I18n.of(context).locale.languageCode).format(_controller.days![index].first.date); + return Tab( + height: 46.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_sameDate(_controller.days![index].first.date, DateTime.now())) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Dot(size: 4.0, color: Theme.of(context).colorScheme.secondary), + ), + Text( + label.substring(0, min(2, label.length)), + style: const TextStyle(fontSize: 26.0, fontWeight: FontWeight.w600), + ), + ], + ), + ); + }), + ), + ], + ) + : const SizedBox(), + ), + ), + ), + ), + ); + } +} + +// difference.inDays is not reliable +bool _sameDate(DateTime a, DateTime b) => (a.year == b.year && a.month == b.month && a.day == b.day); diff --git a/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.i18n.dart b/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.i18n.dart new file mode 100755 index 0000000..75d3bd0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/pages/timetable/timetable_page.i18n.dart @@ -0,0 +1,30 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "timetable": "Timetable", + "empty": "No school this week!", + "week": "Week", + "error": "Failed to fetch timetable!", + }, + "hu_hu": { + "timetable": "Órarend", + "empty": "Ezen a héten nincs iskola.", + "week": "Hét", + "error": "Nem sikerült lekérni az órarendet!", + }, + "de_de": { + "timetable": "Zeitplan", + "empty": "Keine Schule diese Woche.", + "week": "Woche", + "error": "Der Fahrplan konnte nicht abgerufen werden!", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/active_sponsor_card.dart b/filcnaplo_mobile_ui/lib/premium/components/active_sponsor_card.dart new file mode 100755 index 0000000..8474cf3 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/active_sponsor_card.dart @@ -0,0 +1,142 @@ +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/premium/premium_screen.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +class ActiveSponsorCard extends StatelessWidget { + const ActiveSponsorCard({super.key}); + + static PremiumFeatureLevel? estimateLevel(List scopes) { + if (scopes.contains(PremiumScopes.all)) { + return PremiumFeatureLevel.tinta; + } + if (scopes.contains(PremiumScopes.timetableWidget) || scopes.contains(PremiumScopes.goalPlanner)) { + return PremiumFeatureLevel.tinta; + } + if (scopes.contains(PremiumScopes.customColors) || scopes.contains(PremiumScopes.nickname)) { + return PremiumFeatureLevel.kupak; + } + return null; + } + + IconData _levelIcon(PremiumFeatureLevel level) { + switch (level) { + case PremiumFeatureLevel.kupak: + return FilcIcons.kupak; + case PremiumFeatureLevel.tinta: + return FilcIcons.tinta; + } + } + + @override + Widget build(BuildContext context) { + final premium = Provider.of(context, listen: false); + final level = estimateLevel(premium.scopes); + + if (level == null) { + return const SizedBox(); + } + + Color glow; + + switch (level) { + case PremiumFeatureLevel.kupak: + glow = Colors.lightGreen; + break; + case PremiumFeatureLevel.tinta: + glow = Colors.purple; + break; + } + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), + boxShadow: [ + BoxShadow( + color: glow.withOpacity(.4), + blurRadius: 42.0, + ), + ], + ), + child: Card( + margin: EdgeInsets.zero, + elevation: 0, + color: const Color(0xff2B2B2B), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + child: InkWell( + borderRadius: BorderRadius.circular(14.0), + splashColor: glow.withOpacity(.2), + onTap: () { + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) { + return const PremiumScreen(); + })); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Stack( + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: NetworkImage("https://github.com/${premium.login}.png?size=128"), + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomRight, + child: Transform.translate( + offset: const Offset(3.0, 4.0), + child: Container( + padding: const EdgeInsets.all(4.0), + decoration: const BoxDecoration( + color: Color(0xff2B2B2B), + shape: BoxShape.circle, + ), + child: const SizedBox( + height: 14.0, + width: 14.0, + ), + ), + ), + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomRight, + child: SvgPicture.asset( + "assets/images/github.svg", + height: 14.0, + ), + ), + ), + ], + ), + ), + Expanded( + child: Text( + premium.login, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, color: Colors.white), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Icon( + _levelIcon(level), + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/avatar_stack.dart b/filcnaplo_mobile_ui/lib/premium/components/avatar_stack.dart new file mode 100755 index 0000000..844ac11 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/avatar_stack.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class AvatarStack extends StatelessWidget { + const AvatarStack({Key? key, required this.children}) : super(key: key); + + final List children; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + if (children.isNotEmpty) children[0], + if (children.length > 1) + Transform.translate( + offset: const Offset(-20.0, 0.0), + child: children[1], + ), + if (children.length > 2) + Transform.translate( + offset: const Offset(-40.0, 0.0), + child: children[2], + ), + ], + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/github_card.dart b/filcnaplo_mobile_ui/lib/premium/components/github_card.dart new file mode 100755 index 0000000..a6fce26 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/github_card.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class GithubCard extends StatelessWidget { + const GithubCard({super.key, this.onPressed}); + + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + color: const Color(0xff2B2B2B), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(14.0), + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0).add(const EdgeInsets.only(top: 4.0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + "Támogass minket Githubon, hogy megszerezd a jutalmakat!", + style: TextStyle(color: Colors.white), + ), + ), + SvgPicture.asset("assets/images/github.svg"), + ], + ), + const SizedBox(height: 4.0), + Chip( + backgroundColor: Colors.black.withOpacity(.5), + label: const Text( + "Már támogatsz? Jelentkezz be!", + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/github_connect_button.dart b/filcnaplo_mobile_ui/lib/premium/components/github_connect_button.dart new file mode 100755 index 0000000..527ba33 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/github_connect_button.dart @@ -0,0 +1,97 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/activation_view/activation_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +class GithubConnectButton extends StatelessWidget { + const GithubConnectButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final premium = Provider.of(context); + + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: const Color(0xff2B2B2B), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + child: InkWell( + borderRadius: BorderRadius.circular(14.0), + onTap: () { + if (premium.hasPremium) { + premium.auth.refreshAuth(removePremium: true); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + "Prémium deaktiválva.", + style: TextStyle(color: AppColors.of(context).text, fontWeight: FontWeight.bold, fontSize: 18.0), + ), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + )); + return; + } + + Navigator.of(context).push(MaterialPageRoute(builder: (context) { + return const PremiumActivationView(); + })); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Stack( + children: [ + SvgPicture.asset( + "assets/images/github.svg", + height: 32.0, + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomRight, + child: Transform.translate( + offset: const Offset(3.0, 4.0), + child: Container( + padding: const EdgeInsets.all(4.0), + decoration: const BoxDecoration( + color: Color(0xff2B2B2B), + shape: BoxShape.circle, + ), + child: const SizedBox( + height: 14.0, + width: 14.0, + ), + ), + ), + ), + ), + Positioned.fill( + child: Align( + alignment: Alignment.bottomRight, + child: Transform.translate( + offset: const Offset(2.0, 2.0), + child: Icon( + premium.hasPremium ? FeatherIcons.minusCircle : FeatherIcons.plusCircle, + color: Colors.white, + size: 16.0, + ), + ), + ), + ), + ], + ), + ), + Text( + premium.hasPremium ? "GitHub szétkapcsolása" : "GitHub csatlakoztatása", + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/goal_card.dart b/filcnaplo_mobile_ui/lib/premium/components/goal_card.dart new file mode 100755 index 0000000..ae82166 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/goal_card.dart @@ -0,0 +1,74 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class PremiumGoalCard extends StatelessWidget { + const PremiumGoalCard({Key? key, this.progress = 100, this.target = 1}) : super(key: key); + + final double progress; + final double target; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Cél: ${target.round()} támogató", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18.0), + ), + const SizedBox(height: 8.0), + Stack( + alignment: Alignment.center, + children: [ + Container( + height: 12, + decoration: BoxDecoration( + color: Colors.black.withOpacity(.2), + borderRadius: BorderRadius.circular(45.0), + ), + ), + LayoutBuilder( + builder: (context, size) { + return Align( + alignment: Alignment.centerLeft, + child: Row( + children: [ + Container( + height: 12, + width: size.maxWidth * (progress / 100), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFFF2A9D), Color(0xFFFF37F7)]), + borderRadius: BorderRadius.circular(45.0), + ), + ), + Transform.translate( + offset: const Offset(-15.0, 0), + child: Stack( + children: [ + ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: Image.asset("assets/images/heart.png", color: Colors.black.withOpacity(.3)), + ), + Image.asset("assets/images/heart.png"), + ], + ), + ), + ], + ), + ); + }, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/plan_card.dart b/filcnaplo_mobile_ui/lib/premium/components/plan_card.dart new file mode 100755 index 0000000..e97144a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/plan_card.dart @@ -0,0 +1,138 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PremiumPlanCard extends StatelessWidget { + const PremiumPlanCard({ + Key? key, + this.icon, + this.title, + this.description, + this.price = 0, + this.url, + this.gradient, + this.active = false, + }) : super(key: key); + + final Widget? icon; + final Widget? title; + final int price; + final Widget? description; + final String? url; + final Gradient? gradient; + final bool active; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), + child: InkWell( + customBorder: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), + onTap: () { + if (url != null) { + launchUrl( + Uri.parse(url!), + mode: LaunchMode.externalApplication, + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!active) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + IconTheme( + data: Theme.of(context).iconTheme.copyWith(size: 42.0), + child: icon!, + ), + const SizedBox(height: 12.0), + ], + DefaultTextStyle( + style: Theme.of(context).textTheme.displaySmall!.copyWith(fontWeight: FontWeight.bold, fontSize: 25.0), + child: title!, + ), + ], + ), + ) + else + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(99.0), + ), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(99.0), + ), + margin: const EdgeInsets.all(4.0), + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: const Text( + "Aktív", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + ), + ), + ), + ), + Text.rich( + TextSpan(children: [ + TextSpan(text: "\$$price"), + TextSpan( + text: " / hó", + style: TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color!.withOpacity(.7)), + ), + ]), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24.0), + ), + ], + ), + if (active) ...[ + const SizedBox(height: 18.0), + Row( + children: [ + if (icon != null) ...[ + IconTheme( + data: Theme.of(context).iconTheme.copyWith(size: 24.0, color: AppColors.of(context).text), + child: icon!, + ), + ], + const SizedBox(width: 12.0), + DefaultTextStyle( + style: Theme.of(context).textTheme.displaySmall!.copyWith(fontWeight: FontWeight.bold, fontSize: 25.0), + child: title!, + ), + ], + ), + ], + const SizedBox(height: 6.0), + if (description != null) + DefaultTextStyle( + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(color: Theme.of(context).textTheme.bodyMedium!.color!.withOpacity(.8), fontSize: 18), + child: description!, + ), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/reward_card.dart b/filcnaplo_mobile_ui/lib/premium/components/reward_card.dart new file mode 100755 index 0000000..fc356d3 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/reward_card.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +class PremiumRewardCard extends StatelessWidget { + const PremiumRewardCard({Key? key, this.imageKey, this.icon, this.title, this.description, this.soon = false}) : super(key: key); + + final String? imageKey; + final Widget? icon; + final Widget? title; + final Widget? description; + final bool soon; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (soon) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Chip( + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.symmetric(horizontal: 12.0), + backgroundColor: Color(0x777645D3), + label: Text("Hamarosan", style: TextStyle(fontWeight: FontWeight.w500)), + ), + ), + if (imageKey != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14.0).add(EdgeInsets.only(bottom: 12.0, top: soon ? 0 : 14.0)), + child: Image.asset("assets/images/${imageKey!}.png"), + ) + else + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + children: [ + if (icon != null) ...[icon!, const SizedBox(width: 12.0)], + if (title != null) + Expanded( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, fontSize: 20), + child: title!, + ), + ), + ], + ), + ), + if (description != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0).add(const EdgeInsets.only(top: 4.0, bottom: 12.0)), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 16), + child: description!, + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/supporter_chip.dart b/filcnaplo_mobile_ui/lib/premium/components/supporter_chip.dart new file mode 100755 index 0000000..3c67dbf --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/supporter_chip.dart @@ -0,0 +1,35 @@ +import 'package:filcnaplo/models/supporter.dart'; +import 'package:flutter/material.dart'; + +class SupporterChip extends StatelessWidget { + const SupporterChip({Key? key, required this.supporter}) : super(key: key); + + final Supporter supporter; + + @override + Widget build(BuildContext context) { + return Chip( + side: BorderSide.none, + shape: const StadiumBorder(side: BorderSide.none), + padding: const EdgeInsets.all(8.0), + avatar: supporter.avatar != "" + ? CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: NetworkImage(supporter.avatar), + ) + : null, + labelPadding: const EdgeInsets.only(left: 12.0, right: 8.0), + label: Text.rich( + TextSpan(children: [ + TextSpan(text: supporter.name), + if (supporter.type == DonationType.once) + TextSpan( + text: " \$${supporter.price}", + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ]), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/supporter_group_card.dart b/filcnaplo_mobile_ui/lib/premium/components/supporter_group_card.dart new file mode 100755 index 0000000..93b78d4 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/supporter_group_card.dart @@ -0,0 +1,71 @@ +import 'package:filcnaplo/models/supporter.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/supporter_chip.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/supporter_tile.dart'; +import 'package:flutter/material.dart'; + +class SupporterGroupCard extends StatelessWidget { + const SupporterGroupCard({ + Key? key, + this.title, + this.icon, + this.expanded = false, + this.supporters = const [], + this.glow, + }) : super(key: key); + + final Widget? icon; + final Widget? title; + final bool expanded; + final List supporters; + final Color? glow; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), + boxShadow: [ + if (glow != null) + BoxShadow( + color: glow!.withOpacity(.2), + blurRadius: 60.0, + ), + ], + ), + child: Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[icon!, const SizedBox(width: 12.0)], + if (title != null) + Expanded( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w700), + child: title!, + ), + ), + ], + ), + const SizedBox(height: 12.0), + if (expanded) + Column( + children: supporters.map((e) => SupporterTile(supporter: e)).toList(), + ) + else + Wrap( + spacing: 8.0, + children: supporters.map((e) => SupporterChip(supporter: e)).toList(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/supporter_tile.dart b/filcnaplo_mobile_ui/lib/premium/components/supporter_tile.dart new file mode 100755 index 0000000..123f3fe --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/supporter_tile.dart @@ -0,0 +1,23 @@ +import 'package:filcnaplo/models/supporter.dart'; +import 'package:flutter/material.dart'; + +class SupporterTile extends StatelessWidget { + const SupporterTile({Key? key, required this.supporter}) : super(key: key); + + final Supporter supporter; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundImage: NetworkImage(supporter.avatar), + ), + title: Text( + supporter.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(supporter.comment), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/components/supporters_button.dart b/filcnaplo_mobile_ui/lib/premium/components/supporters_button.dart new file mode 100755 index 0000000..9567441 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/components/supporters_button.dart @@ -0,0 +1,70 @@ +import 'dart:math'; + +import 'package:filcnaplo/models/supporter.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/avatar_stack.dart'; +import 'package:filcnaplo_mobile_ui/premium/supporters_screen.dart'; +import 'package:flutter/material.dart'; + +class SupportersButton extends StatelessWidget { + const SupportersButton({Key? key, required this.supporters}) : super(key: key); + + final Future supporters; + + @override + Widget build(BuildContext context) { + return Card( + shape: const StadiumBorder(), + margin: EdgeInsets.zero, + child: InkWell( + customBorder: const StadiumBorder(), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SupportersScreen(supporters: supporters)), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 14.0), + child: Row( + children: [ + const Expanded( + child: Text( + "Köszönjük, támogatók!", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17.0), + ), + ), + FutureBuilder( + future: supporters, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + final sponsors = snapshot.data!.github.where((e) => e.type == DonationType.monthly).toList(); + sponsors.shuffle(Random((DateTime.now().millisecondsSinceEpoch / 1000 / 60 / 60 / 24).floor())); + return AvatarStack( + children: [ + // ignore: prefer_is_empty + if (sponsors.length > 0 && sponsors[0].avatar != "") + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: NetworkImage(sponsors[0].avatar), + ), + if (sponsors.length > 1 && sponsors[1].avatar != "") + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: NetworkImage(sponsors[1].avatar), + ), + if (sponsors.length > 2 && sponsors[2].avatar != "") + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundImage: NetworkImage(sponsors[2].avatar), + ), + ], + ); + }), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/premium_button.dart b/filcnaplo_mobile_ui/lib/premium/premium_button.dart new file mode 100755 index 0000000..6dc519f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/premium_button.dart @@ -0,0 +1,119 @@ +import 'dart:ui'; + +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/premium/premium_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:animations/animations.dart'; + +class PremiumButton extends StatefulWidget { + const PremiumButton({Key? key}) : super(key: key); + + @override + State createState() => _PremiumButtonState(); +} + +class _PremiumButtonState extends State with TickerProviderStateMixin { + late final AnimationController _animation; + bool _heldDown = false; + + @override + void initState() { + super.initState(); + _animation = AnimationController(vsync: this, duration: const Duration(seconds: 3)); + _animation.repeat(); + } + + @override + void dispose() { + _animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return OpenContainer( + openColor: Theme.of(context).scaffoldBackgroundColor, + closedColor: Theme.of(context).scaffoldBackgroundColor, + clipBehavior: Clip.none, + transitionType: ContainerTransitionType.fadeThrough, + openElevation: 0, + closedElevation: 0, + closedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + openBuilder: (context, _) => const PremiumScreen(), + closedBuilder: (context, action) => GestureDetector( + onTapDown: (_) => setState(() => _heldDown = true), + onTapUp: (_) => setState(() => _heldDown = false), + onTapCancel: () => setState(() => _heldDown = false), + onTap: action, + child: Stack( + alignment: Alignment.center, + children: [ + // RGB background animation + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(14.0), + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: Container( + height: 70, + decoration: BoxDecoration( + gradient: SweepGradient(colors: const [ + Colors.blue, + Colors.orange, + Colors.purple, + Colors.blue, + ], transform: GradientRotation(_animation.value * 6.283185)), + ), + ), + ), + ), + ); + }), + + // Button background & text + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6.0, sigmaY: 6.0), + child: AnimatedScale( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + scale: _heldDown ? 1.03 : 1, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24.0), + width: double.infinity, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.0), + gradient: const LinearGradient(colors: [ + Color(0xff124F3D), + Color(0xff1EA18F), + ]), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(FilcIcons.premium, color: Colors.white), + SizedBox(width: 12.0), + Text( + "Filc Napló Premium", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/premium_screen.dart b/filcnaplo_mobile_ui/lib/premium/premium_screen.dart new file mode 100755 index 0000000..60beeff --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/premium_screen.dart @@ -0,0 +1,292 @@ +import 'package:filcnaplo/api/client.dart'; +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo/models/supporter.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/active_sponsor_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/github_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/github_connect_button.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/goal_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/plan_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/reward_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/supporters_button.dart'; +import 'package:filcnaplo_mobile_ui/premium/styles/gradients.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/activation_view/activation_view.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +class PremiumScreen extends StatelessWidget { + const PremiumScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final middleColor = + Theme.of(context).brightness == Brightness.dark ? const Color.fromARGB(255, 20, 57, 46) : const Color.fromARGB(255, 10, 140, 123); + + final future = FilcAPI.getSupporters(); + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + return Scaffold( + body: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverAppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + automaticallyImplyLeading: false, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xff124F3D), + middleColor, + ], + ), + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.close, color: Colors.white), + ), + ), + ], + ), + SliverPadding( + padding: const EdgeInsets.only(bottom: 25.0), + sliver: SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + middleColor, + Theme.of(context).scaffoldBackgroundColor, + ], + ), + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 64.0), + Image.asset("assets/images/logo.png"), + const SizedBox(height: 12.0), + const Text( + "Még több filc.", + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 25.0, color: Colors.white), + ), + const Text( + "Filc Premium.", + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 35.0, color: Colors.white), + ), + const SizedBox(height: 15.0), + Text( + "Támogasd a filcet, és szerezz cserébe pár kényelmes jutalmat!", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20, color: Colors.white.withOpacity(.8)), + ), + const SizedBox(height: 25.0), + SupportersButton(supporters: future), + ], + ), + ), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 24.0).add(const EdgeInsets.only(bottom: 100)), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + PremiumPlanCard( + icon: const Icon(FilcIcons.kupak), + title: Text("Kupak", style: TextStyle(foreground: GradientStyles.kupakPaint)), + gradient: GradientStyles.kupak, + price: 2, + description: const Text("Szabd személyre a filcet és láss részletesebb statisztikákat."), + url: "https://github.com/sponsors/filc/sponsorships?tier_id=238453&preview=true", + active: ActiveSponsorCard.estimateLevel(context.watch().scopes) == PremiumFeatureLevel.kupak, + ), + const SizedBox(height: 8.0), + PremiumPlanCard( + icon: const Icon(FilcIcons.tinta), + title: Text("Tinta", style: TextStyle(foreground: GradientStyles.tintaPaint)), + gradient: GradientStyles.tinta, + price: 5, + description: const Text("Kényelmesebb órarend, asztali alkalmazás és célok kitűzése."), + url: "https://github.com/sponsors/filc/sponsorships?tier_id=238454&preview=true", + active: ActiveSponsorCard.estimateLevel(context.watch().scopes) == PremiumFeatureLevel.tinta, + ), + const SizedBox(height: 12.0), + PremiumGoalCard(progress: snapshot.data?.progress ?? 0, target: snapshot.data?.max ?? 1), + const SizedBox(height: 12.0), + const GithubConnectButton(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14.0).add(const EdgeInsets.only(top: 12.0)), + child: Row( + children: const [ + Icon(FilcIcons.kupak), + SizedBox(width: 12.0), + Expanded( + child: Text( + "Kupak jutalmak", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ), + ], + ), + ), + PremiumRewardCard( + imageKey: "premium_nickname_showcase", + icon: SvgPicture.asset("assets/images/nickname_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Profil személyre szabás"), + description: const Text("Állíts be egy saját becenevet és egy profilképet (akár animáltat is!)"), + ), + const SizedBox(height: 14.0), + PremiumRewardCard( + imageKey: "premium_theme_showcase", + icon: SvgPicture.asset("assets/images/theme_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Téma+"), + description: const Text("Válassz saját háttérszínt és kártyaszínt is, akár saját HEX-kóddal!"), + ), + const SizedBox(height: 14.0), + PremiumRewardCard( + imageKey: "premium_stats_showcase", + icon: SvgPicture.asset("assets/images/stats_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Részletes jegy statisztika"), + description: const Text("Válassz heti, havi és háromhavi időtartam közül, és pontosan lásd, mennyi jegyed van."), + ), + const SizedBox(height: 14.0), + const PremiumRewardCard( + title: Text("Még pár dolog..."), + description: + Text("🔣\tVálassz ikon témát\n✨\tPrémium rang és csevegő a discord szerverünkön\n📬\tElsőbbségi segítségnyújtás"), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14.0).add(const EdgeInsets.only(top: 12.0)), + child: Row( + children: const [ + Icon(FilcIcons.tinta), + SizedBox(width: 12.0), + Expanded( + child: Text( + "Tinta jutalmak", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ), + ], + ), + ), + PremiumRewardCard( + imageKey: "premium_timetable_showcase", + icon: SvgPicture.asset("assets/images/timetable_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Heti órarend nézet"), + description: + const Text("Egy órarend, ami a teljes képernyődet kihasználja, csak nem olyan idegesítő, mint az eKRÉTA féle."), + ), + const SizedBox(height: 14.0), + PremiumRewardCard( + imageKey: "premium_widget_showcase", + icon: SvgPicture.asset("assets/images/widget_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Widget"), + description: const Text("Mindig lásd, milyen órád lesz, a kezdőképernyőd kényelméből."), + ), + const SizedBox(height: 14.0), + PremiumRewardCard( + soon: true, + imageKey: "premium_goal_showcase", + icon: SvgPicture.asset("assets/images/goal_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Cél követés"), + description: const Text("Add meg, mi a célod, és mi majd kiszámoljuk, hogyan juthatsz oda!"), + ), + const SizedBox(height: 14.0), + PremiumRewardCard( + soon: true, + imageKey: "premium_desktop_showcase", + icon: SvgPicture.asset("assets/images/desktop_icon.svg", color: Theme.of(context).iconTheme.color), + title: const Text("Asztali verzió"), + description: const Text("Érd el a Filc Napló-t a gépeden is, és menekülj meg a csúnya felhasználói felületektől!"), + ), + const SizedBox(height: 14.0), + const PremiumRewardCard( + title: Text("Még pár dolog..."), + description: Text("🖋️\tMinden kupak jutalom\n✨\tKorai hozzáférés új verziókhoz"), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14.0).add(const EdgeInsets.only(top: 12.0)), + child: Row( + children: const [ + SizedBox(width: 12.0), + Expanded( + child: Text( + "Mire vársz még?", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ), + ], + ), + ), + GithubCard( + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) { + return const PremiumActivationView(); + })); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14.0).add(const EdgeInsets.only(top: 12.0)), + child: Row( + children: const [ + SizedBox(width: 12.0), + Expanded( + child: Text( + "Gyakori kérdések", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ), + ], + ), + ), + const PremiumRewardCard( + title: Text("Mire költitek a pénzt?"), + description: Text( + "A pénz elsősorban az appstore évi \$100-os díját fedezi, a maradék a szerver a weboldal és új funkciók fejlesztésére fordítjuk."), + ), + const SizedBox(height: 14.0), + const PremiumRewardCard( + title: Text("Még mindig nyílt a forráskód?"), + description: Text( + "Igen, a Filc napló teljesen nyílt forráskódú, és ez így is fog maradni. A prémium funkciók forráskódjához hozzáférnek a támogatók."), + ), + const SizedBox(height: 14.0), + const PremiumRewardCard( + title: Text("Hol tudok támogatni?"), + description: Text( + "A támogatáshoz szükséged van egy Github profilra, amit hozzá kell kötnöd a filc naplóhoz. A Github “Sponsors” funkciója segítségével kezeljük az támogatásod."), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/premium/styles/gradients.dart b/filcnaplo_mobile_ui/lib/premium/styles/gradients.dart new file mode 100755 index 0000000..17fa6de --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/styles/gradients.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +class GradientStyles { + static const tinta = LinearGradient( + colors: [Color(0xffB816E0), Color(0xff17D1BB)], + ); + static final tintaPaint = Paint()..shader = tinta.createShader(const Rect.fromLTWH(0, 0, 200, 70)); + + static const kupak = LinearGradient( + colors: [Color(0xffF0BD0C), Color(0xff0CD070)], + ); + static final kupakPaint = Paint()..shader = kupak.createShader(const Rect.fromLTWH(0, 0, 200, 70)); +} diff --git a/filcnaplo_mobile_ui/lib/premium/supporters_screen.dart b/filcnaplo_mobile_ui/lib/premium/supporters_screen.dart new file mode 100755 index 0000000..a7bc0aa --- /dev/null +++ b/filcnaplo_mobile_ui/lib/premium/supporters_screen.dart @@ -0,0 +1,121 @@ +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo/models/supporter.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/supporter_group_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/styles/gradients.dart'; +import 'package:flutter/material.dart'; + +class SupportersScreen extends StatelessWidget { + const SupportersScreen({Key? key, required this.supporters}) : super(key: key); + + final Future supporters; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: supporters, + builder: (context, snapshot) { + final highlightedSupporters = + snapshot.data?.github.where((e) => e.type == DonationType.monthly && e.price >= 5 && e.comment != "").toList() ?? []; + final tintaSupporters = + snapshot.data?.github.where((e) => e.type == DonationType.monthly && e.price >= 5 && e.comment == "").toList() ?? []; + final kupakSupporters = snapshot.data?.github.where((e) => e.type == DonationType.monthly && e.price == 2).toList() ?? []; + final onetimeSupporters = snapshot.data?.github.where((e) => e.type == DonationType.once && e.price >= 5).toList() ?? []; + final patreonSupporters = snapshot.data?.patreon ?? []; + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar.large( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + title: const Text( + "Támogatók", + style: TextStyle(fontWeight: FontWeight.w700), + ), + ), + if (snapshot.hasData) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16.0).add(const EdgeInsets.only(bottom: 24.0)), + sliver: SliverToBoxAdapter( + child: Text( + snapshot.data!.description, + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20.0, color: AppColors.of(context).text.withOpacity(.7)), + ), + ), + ), + if (!snapshot.hasData) + const SliverPadding( + padding: EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + ), + if (highlightedSupporters.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: SupporterGroupCard( + title: const Text("Kiemelt támogatók"), + expanded: true, + supporters: highlightedSupporters, + ), + ), + ), + if (tintaSupporters.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: SupporterGroupCard( + icon: const Icon(FilcIcons.tinta), + title: Text( + "Tinta", + style: TextStyle( + foreground: GradientStyles.tintaPaint, + ), + ), + glow: Colors.purple, + supporters: tintaSupporters, + ), + ), + ), + if (kupakSupporters.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: SupporterGroupCard( + icon: const Icon(FilcIcons.kupak), + title: Text( + "Kupak", + style: TextStyle(foreground: GradientStyles.kupakPaint), + ), + glow: Colors.lightGreen, + supporters: kupakSupporters, + ), + ), + ), + if (onetimeSupporters.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: SupporterGroupCard( + title: const Text("Egyszeri támogatók"), + supporters: onetimeSupporters, + ), + ), + ), + if (patreonSupporters.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverToBoxAdapter( + child: SupporterGroupCard( + title: const Text("Régebbi támogatóink"), + supporters: patreonSupporters, + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/error_report_screen.dart b/filcnaplo_mobile_ui/lib/screens/error_report_screen.dart new file mode 100755 index 0000000..2ecb642 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/error_report_screen.dart @@ -0,0 +1,200 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:filcnaplo/api/client.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'error_report_screen.i18n.dart'; + +class ErrorReportScreen extends StatelessWidget { + final FlutterErrorDetails details; + + const ErrorReportScreen(this.details, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.red, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + children: [ + const Align( + child: BackButton(), + alignment: Alignment.topLeft, + ), + const Spacer(), + const Icon( + FeatherIcons.alertTriangle, + size: 100, + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + "uhoh".i18n, + style: const TextStyle( + color: Colors.white, + fontSize: 32.0, + fontWeight: FontWeight.w900, + ), + ), + ), + Text( + "description".i18n, + style: TextStyle( + color: Colors.white.withOpacity(.95), + fontSize: 24.0, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Stack( + alignment: Alignment.topRight, + children: [ + Container( + height: 110.0, + width: double.infinity, + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.0), color: Colors.black.withOpacity(.2)), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Text( + details.exceptionAsString(), + style: const TextStyle(fontFamily: 'SpaceMono'), + ), + ), + ), + IconButton( + icon: const Icon(FeatherIcons.info), + onPressed: () { + showDialog(context: context, builder: (context) => StacktracePopup(details)); + }, + ) + ], + ), + const Spacer(), + SizedBox( + width: double.infinity, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(const EdgeInsets.symmetric(vertical: 14.0)), + backgroundColor: MaterialStateProperty.all(Colors.white), + shape: MaterialStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + ), + ), + child: Text( + "submit".i18n, + style: const TextStyle( + color: Colors.black, + fontSize: 17.0, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () => reportProblem(context), + ), + ), + const SizedBox(height: 32.0) + ], + ), + ), + ), + ); + } + + Future reportProblem(BuildContext context) async { + final report = ErrorReport( + os: Platform.operatingSystem + " " + Platform.operatingSystemVersion, + error: details.exceptionAsString(), + version: const String.fromEnvironment("APPVER", defaultValue: "?"), + stack: details.stack.toString(), + ); + FilcAPI.sendReport(report); + Navigator.pop(context); + } +} + +class StacktracePopup extends StatelessWidget { + final FlutterErrorDetails details; + + const StacktracePopup(this.details, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + String stack = details.stack.toString(); + + return Container( + margin: const EdgeInsets.all(32.0), + child: Scaffold( + backgroundColor: Colors.transparent, + body: Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(4.0), + ), + padding: const EdgeInsets.only(top: 15.0, right: 15.0, left: 15.0), + child: Column( + children: [ + Expanded( + child: ListView(children: [ + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: Text( + "details".i18n, + style: const TextStyle(fontSize: 20.0), + ), + ), + ErrorDetail( + "error".i18n, + details.exceptionAsString(), + ), + ErrorDetail("os".i18n, Platform.operatingSystem + " " + Platform.operatingSystemVersion), + ErrorDetail("version".i18n, const String.fromEnvironment("APPVER", defaultValue: "?")), + ErrorDetail("stack".i18n, stack.substring(0, min(stack.length, 5000))) + ]), + ), + TextButton( + child: Text("done".i18n, style: TextStyle(color: Theme.of(context).colorScheme.secondary)), + onPressed: () { + Navigator.of(context).pop(); + }) + ], + ), + ), + ), + ); + } +} + +class ErrorDetail extends StatelessWidget { + final String title; + final String content; + + const ErrorDetail(this.title, this.content, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Container( + child: Text( + content, + style: const TextStyle(fontFamily: 'SpaceMono', color: Colors.white), + ), + padding: const EdgeInsets.symmetric(horizontal: 6.5, vertical: 4.0), + margin: const EdgeInsets.only(top: 4.0), + decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(4.0))) + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/error_report_screen.i18n.dart b/filcnaplo_mobile_ui/lib/screens/error_report_screen.i18n.dart new file mode 100755 index 0000000..498e799 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/error_report_screen.i18n.dart @@ -0,0 +1,45 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension SettingsLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "uhoh": "Uh Oh!", + "description": "An error occurred!", + "submit": "Submit", + "details": "Details", + "error": "Error", + "os": "Operating System", + "version": "App Version", + "stack": "Stack Trace", + "done": "Done", + }, + "hu_hu": { + "uhoh": "Ajajj!", + "description": "Hiba történt!", + "submit": "Probléma Jelentése", + "details": "Részletek", + "error": "Hiba", + "os": "Operációs Rendszer", + "version": "App Verzió", + "stack": "Stacktrace", + "done": "Kész", + }, + "de_de": { + "uhoh": "Uh Oh!", + "description": "Ein Fehler ist aufgetreten!", + "submit": "Abschicken", + "details": "Details", + "error": "Fehler", + "os": "Betriebssystem", + "version": "App Version", + "stack": "Stack Trace", + "done": "Fertig", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/screens/error_screen.dart b/filcnaplo_mobile_ui/lib/screens/error_screen.dart new file mode 100755 index 0000000..0a087cc --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/error_screen.dart @@ -0,0 +1,64 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class ErrorScreen extends StatelessWidget { + const ErrorScreen(this.details, {Key? key}) : super(key: key); + + final FlutterErrorDetails details; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + shadowColor: Colors.transparent, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: Icon(FeatherIcons.alertTriangle, size: 48.0, color: AppColors.of(context).red), + ), + const Padding( + padding: EdgeInsets.all(12.0), + child: Text( + "An error occurred...", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 16.0), + ), + ), + Expanded( + child: Container( + padding: const EdgeInsets.all(12.0), + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14.0), + color: Theme.of(context).colorScheme.background, + ), + child: CupertinoScrollbar( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + scrollDirection: Axis.horizontal, + child: SelectableText( + (details.exceptionAsString() + '\n'), + style: const TextStyle(fontFamily: "monospace"), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/login_button.dart b/filcnaplo_mobile_ui/lib/screens/login/login_button.dart new file mode 100755 index 0000000..4e183a0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/login_button.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class LoginButton extends StatelessWidget { + const LoginButton({Key? key, required this.onPressed, required this.child}) : super(key: key); + + final void Function()? onPressed; + final Widget? child; + + @override + Widget build(BuildContext context) { + return MaterialButton( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, + ), + child: child, + ), + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 0, + minWidth: MediaQuery.of(context).size.width - 64.0, + onPressed: onPressed, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + color: Colors.white, + textColor: Colors.black, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/login_input.dart b/filcnaplo_mobile_ui/lib/screens/login/login_input.dart new file mode 100755 index 0000000..04e8186 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/login_input.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +enum LoginInputStyle { username, password, school } + +class LoginInput extends StatefulWidget { + const LoginInput({Key? key, required this.style, this.controller, this.focusNode, this.onClear}) : super(key: key); + + final Function()? onClear; + final LoginInputStyle style; + final TextEditingController? controller; + final FocusNode? focusNode; + + @override + State createState() => _LoginInputState(); +} + +class _LoginInputState extends State { + late bool obscure; + + @override + void initState() { + super.initState(); + obscure = widget.style == LoginInputStyle.password; + } + + @override + Widget build(BuildContext context) { + String autofill; + + switch (widget.style) { + case LoginInputStyle.username: + autofill = AutofillHints.username; + break; + case LoginInputStyle.password: + autofill = AutofillHints.password; + break; + case LoginInputStyle.school: + autofill = AutofillHints.organizationName; + break; + } + + return TextField( + focusNode: widget.focusNode, + controller: widget.controller, + cursorColor: const Color(0xff20AC9B), + textInputAction: TextInputAction.next, + autofillHints: [autofill], + obscureText: obscure, + scrollPhysics: const BouncingScrollPhysics(), + decoration: InputDecoration( + fillColor: Colors.black.withOpacity(0.15), + filled: true, + enabledBorder: UnderlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + borderSide: const BorderSide(width: 0, color: Colors.transparent), + ), + focusedBorder: UnderlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + borderSide: const BorderSide(width: 0, color: Colors.transparent), + ), + suffixIconConstraints: const BoxConstraints(maxHeight: 42.0, maxWidth: 48.0), + suffixIcon: widget.style == LoginInputStyle.password || widget.style == LoginInputStyle.school + ? ClipOval( + child: Material( + type: MaterialType.transparency, + child: IconButton( + splashRadius: 20.0, + padding: EdgeInsets.zero, + onPressed: () { + if (widget.style == LoginInputStyle.password) { + setState(() => obscure = !obscure); + } else { + widget.controller?.clear(); + if (widget.onClear != null) widget.onClear!(); + } + }, + icon: Icon( + widget.style == LoginInputStyle.password + ? obscure + ? FeatherIcons.eye + : FeatherIcons.eyeOff + : FeatherIcons.x, + color: Colors.white), + ), + ), + ) + : null, + ), + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/login_route.dart b/filcnaplo_mobile_ui/lib/screens/login/login_route.dart new file mode 100755 index 0000000..4b7c3d6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/login_route.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +Route loginRoute(Widget widget) { + return PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => widget, + transitionDuration: const Duration(milliseconds: 650), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + var curve = Curves.easeInOut; + var curveTween = CurveTween(curve: curve); + var begin = const Offset(1.0, 0.0); + var end = Offset.zero; + var tween = Tween(begin: begin, end: end).chain(curveTween); + var offsetAnimation = animation.drive(tween); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + ); +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/login_screen.dart b/filcnaplo_mobile_ui/lib/screens/login/login_screen.dart new file mode 100755 index 0000000..2e3b6e2 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/login_screen.dart @@ -0,0 +1,303 @@ +import 'dart:ui'; + +import 'package:filcnaplo/api/client.dart'; +import 'package:filcnaplo/api/login.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/custom_snack_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/system_chrome.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/login_button.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/login_input.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/school_input/school_input.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'login_screen.i18n.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({Key? key, this.back = false}) : super(key: key); + + final bool back; + + @override + _LoginScreenState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final usernameController = TextEditingController(); + final passwordController = TextEditingController(); + final schoolController = SchoolInputController(); + final _scrollController = ScrollController(); + + LoginState _loginState = LoginState.normal; + bool showBack = false; + + // Scaffold Gradient background + final LinearGradient _backgroundGradient = const LinearGradient( + colors: [ + Color(0xff20AC9B), + Color(0xff20AC9B), + Color(0xff123323), + ], + begin: Alignment(-0.8, -1.0), + end: Alignment(0.8, 1.0), + stops: [-1.0, 0.0, 1.0], + ); + + @override + void initState() { + super.initState(); + showBack = widget.back; + + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: Colors.white, + systemNavigationBarIconBrightness: Brightness.dark, + )); + + FilcAPI.getSchools().then((schools) { + if (schools != null) { + schoolController.update(() { + schoolController.schools = schools; + }); + } else { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + content: Text("schools_error".i18n, style: const TextStyle(color: Colors.white)), + backgroundColor: AppColors.of(context).red, + context: context, + )); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: BoxDecoration(gradient: _backgroundGradient), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + controller: _scrollController, + child: Container( + decoration: BoxDecoration(gradient: _backgroundGradient), + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (showBack) + Container( + alignment: Alignment.topLeft, + padding: const EdgeInsets.only(left: 16.0, top: 12.0), + child: const ClipOval( + child: Material( + type: MaterialType.transparency, + child: BackButton(color: Colors.white), + ), + ), + ), + + const Spacer(), + + // App logo + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: ClipRect( + child: Container( + // Png shadow *hack* + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Opacity(child: Image.asset("assets/icons/ic_splash.png", color: Colors.black), opacity: 0.3), + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6.0, sigmaY: 6.0), + child: Image.asset("assets/icons/ic_splash.png"), + ) + ], + ), + width: MediaQuery.of(context).size.width / 4, + margin: const EdgeInsets.only(left: 12.0, right: 12.0, bottom: 12.0), + ), + ), + ), + + // Inputs + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Username + Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + "username".i18n, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14.0, + ), + ), + ), + Expanded( + child: Text( + "usernameHint".i18n, + maxLines: 1, + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white54, + fontWeight: FontWeight.w500, + fontSize: 12.0, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: LoginInput( + style: LoginInputStyle.username, + controller: usernameController, + ), + ), + + // Password + Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + "password".i18n, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14.0, + ), + ), + ), + Expanded( + child: Text( + "passwordHint".i18n, + maxLines: 1, + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white54, + fontWeight: FontWeight.w500, + fontSize: 12.0, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: LoginInput( + style: LoginInputStyle.password, + controller: passwordController, + ), + ), + + // School + Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Text( + "school".i18n, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14.0, + ), + ), + ), + SchoolInput( + scroll: _scrollController, + controller: schoolController, + ), + ], + ), + ), + ), + + // Log in button + Padding( + padding: const EdgeInsets.only(top: 42.0), + child: Visibility( + child: LoginButton( + child: Text("login".i18n, + maxLines: 1, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15.0, + )), + onPressed: () => _loginApi(context: context), + ), + visible: _loginState != LoginState.inProgress, + replacement: const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + ), + if (_loginState == LoginState.missingFields || _loginState == LoginState.invalidGrant || _loginState == LoginState.failed) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + ["missing_fields", "invalid_grant", "error"][_loginState.index].i18n, + style: const TextStyle(color: Colors.red, fontWeight: FontWeight.w500), + ), + ), + const Spacer() + ], + ), + ), + ), + ), + ), + ); + } + + void _loginApi({required BuildContext context}) { + String username = usernameController.text; + String password = passwordController.text; + + if (username == "" || password == "" || schoolController.selectedSchool == null) { + return setState(() => _loginState = LoginState.missingFields); + } + + setState(() => _loginState = LoginState.inProgress); + + loginApi( + username: username, + password: password, + instituteCode: schoolController.selectedSchool!.instituteCode, + context: context, + onLogin: (user) { + ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar( + context: context, + brightness: Brightness.light, + content: Text("welcome".i18n.fill([user.name]), overflow: TextOverflow.ellipsis), + )); + }, + onSuccess: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + setSystemChrome(context); + Navigator.of(context).pushReplacementNamed("login_to_navigation"); + }).then((res) => setState(() => _loginState = res)); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/login_screen.i18n.dart b/filcnaplo_mobile_ui/lib/screens/login/login_screen.i18n.dart new file mode 100755 index 0000000..f463081 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/login_screen.i18n.dart @@ -0,0 +1,51 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "username": "Username", + "usernameHint": "Student ID number", + "password": "Password", + "passwordHint": "Date of birth", + "school": "School", + "login": "Log in", + "welcome": "Welcome, %s!", + "missing_fields": "Missing Fields!", + "invalid_grant": "Invalid Username/Password!", + "error": "Failed to log in.", + "schools_error": "Failed to get schools." + }, + "hu_hu": { + "username": "Felhasználónév", + "usernameHint": "Oktatási azonosító", + "password": "Jelszó", + "passwordHint": "Születési dátum", + "school": "Iskola", + "login": "Belépés", + "welcome": "Üdv, %s!", + "missing_fields": "Hiányzó adatok!", + "invalid_grant": "Helytelen Felhasználónév/Jelszó!", + "error": "Sikertelen bejelentkezés.", + "schools_error": "Nem sikerült lekérni az iskolákat." + }, + "de_de": { + "username": "Benutzername", + "usernameHint": "Ausbildung ID", + "password": "Passwort", + "passwordHint": "Geburtsdatum", + "school": "Schule", + "login": "Einloggen", + "welcome": "Wilkommen, %s!", + "missing_fields": "Fehlende Felder!", + "invalid_grant": "Ungültiger Benutzername/Passwort!", + "error": "Anmeldung fehlgeschlagen.", + "schools_error": "Keine Schulen gefunden." + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input.dart b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input.dart new file mode 100755 index 0000000..198949f --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input.dart @@ -0,0 +1,117 @@ +import 'package:filcnaplo_mobile_ui/screens/login/login_input.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/school_input/school_input_overlay.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/school_input/school_input_tile.dart'; +import 'package:filcnaplo_mobile_ui/screens/login/school_input/school_search.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo_kreta_api/models/school.dart'; + +class SchoolInput extends StatefulWidget { + const SchoolInput({Key? key, required this.controller, required this.scroll}) : super(key: key); + + final SchoolInputController controller; + final ScrollController scroll; + + @override + _SchoolInputState createState() => _SchoolInputState(); +} + +class _SchoolInputState extends State { + final _focusNode = FocusNode(); + final _layerLink = LayerLink(); + late SchoolInputOverlay overlay; + + @override + void initState() { + super.initState(); + + widget.controller.update = (fn) { + if (mounted) setState(fn); + }; + + overlay = SchoolInputOverlay(layerLink: _layerLink); + + // Show school list when focused + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) => overlay.createOverlayEntry(context)); + Future.delayed(const Duration(milliseconds: 100)).then((value) { + if (mounted && widget.scroll.hasClients) { + widget.scroll.animateTo(widget.scroll.offset + 500, duration: const Duration(milliseconds: 500), curve: Curves.ease); + } + }); + } else { + overlay.entry?.remove(); + } + }); + + // LoginInput TextField listener + widget.controller.textController.addListener(() { + String text = widget.controller.textController.text; + if (text.isEmpty) { + overlay.children = null; + return; + } + + List results = searchSchools(widget.controller.schools ?? [], text); + setState(() { + overlay.children = results + .map((School e) => SchoolInputTile( + school: e, + onTap: () => _selectSchool(e), + )) + .toList(); + }); + Overlay.of(context).setState(() {}); + }); + } + + void _selectSchool(School school) { + FocusScope.of(context).requestFocus(FocusNode()); + + setState(() { + widget.controller.selectedSchool = school; + widget.controller.textController.text = school.name; + }); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: widget.controller.schools == null + ? Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10.0), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(12.0), + ), + child: const Center( + child: SizedBox( + height: 28.0, + width: 28.0, + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ), + ) + : LoginInput( + style: LoginInputStyle.school, + focusNode: _focusNode, + onClear: () { + widget.controller.selectedSchool = null; + FocusScope.of(context).requestFocus(_focusNode); + }, + controller: widget.controller.textController, + ), + ); + } +} + +class SchoolInputController { + final textController = TextEditingController(); + School? selectedSchool; + List? schools; + late void Function(void Function()) update; +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.dart b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.dart new file mode 100755 index 0000000..85330bb --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'school_input_overlay.i18n.dart'; + +class SchoolInputOverlay { + OverlayEntry? entry; + final LayerLink layerLink; + List? children; + + SchoolInputOverlay({required this.layerLink}); + + void createOverlayEntry(BuildContext context) { + entry = OverlayEntry(builder: (_) => buildOverlayEntry(context)); + Overlay.of(context).insert(entry!); + } + + Widget buildOverlayEntry(BuildContext context) { + RenderBox renderBox = context.findRenderObject()! as RenderBox; + var size = renderBox.size; + return SchoolInputOverlayWidget( + children: children, + size: size, + layerLink: layerLink, + ); + } +} + +class SchoolInputOverlayWidget extends StatelessWidget { + const SchoolInputOverlayWidget({ + Key? key, + required this.children, + required this.size, + required this.layerLink, + }) : super(key: key); + + final Size size; + final List? children; + final LayerLink layerLink; + + @override + Widget build(BuildContext context) { + return children != null + ? Positioned( + width: size.width, + height: (children?.length ?? 0) > 0 ? 150.0 : 50.0, + child: CompositedTransformFollower( + link: layerLink, + showWhenUnlinked: false, + offset: Offset(0.0, size.height + 5.0), + child: Material( + color: Theme.of(context).colorScheme.background, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + elevation: 4.0, + shadowColor: Colors.black, + child: (children?.length ?? 0) > 0 + ? ListView.builder( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: children?.length ?? 0, + itemBuilder: (context, index) { + return children?[index] ?? Container(); + }, + ) + : Center( + child: Text("noresults".i18n), + ), + ), + ), + ) + : Container(); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.i18n.dart b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.i18n.dart new file mode 100755 index 0000000..486d1d6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_overlay.i18n.dart @@ -0,0 +1,21 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "noresults": "No results!", + }, + "hu_hu": { + "noresults": "Nincs találat!", + }, + "de_de": { + "noresults": "Keine Treffer!", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_tile.dart b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_tile.dart new file mode 100755 index 0000000..d6d25f3 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_input_tile.dart @@ -0,0 +1,64 @@ +import 'package:filcnaplo_kreta_api/models/school.dart'; +import 'package:flutter/material.dart'; + +class SchoolInputTile extends StatelessWidget { + const SchoolInputTile({Key? key, required this.school, this.onTap}) : super(key: key); + + final School school; + final Function()? onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: GestureDetector( + onPanDown: (e) { + onTap!(); + }, + child: InkWell( + onTapDown: (e) {}, + borderRadius: BorderRadius.circular(6.0), + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // School name + Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + school.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Row( + children: [ + // School id + Expanded( + child: Text( + school.instituteCode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // School city + Expanded( + child: Text( + school.city, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/login/school_input/school_search.dart b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_search.dart new file mode 100755 index 0000000..f721259 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/login/school_input/school_search.dart @@ -0,0 +1,25 @@ +import 'package:filcnaplo_kreta_api/models/school.dart'; +import 'package:filcnaplo/utils/format.dart'; + +List searchSchools(List all, String pattern) { + pattern = pattern.toLowerCase().specialChars(); + if (pattern == "") return all; + + List results = []; + + for (var item in all) { + int contains = 0; + + pattern.split(" ").forEach((variation) { + if (item.name.toLowerCase().specialChars().contains(variation)) { + contains++; + } + }); + + if (contains == pattern.split(" ").length) results.add(item); + } + + results.sort((a, b) => a.name.compareTo(b.name)); + + return results; +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/nabar.dart b/filcnaplo_mobile_ui/lib/screens/navigation/nabar.dart new file mode 100755 index 0000000..bc35219 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/nabar.dart @@ -0,0 +1,27 @@ +import 'package:filcnaplo_mobile_ui/screens/navigation/navbar_item.dart'; +import 'package:flutter/material.dart'; + +class Navbar extends StatelessWidget { + const Navbar({Key? key, required this.selectedIndex, required this.onSelected, required this.items}) : super(key: key); + + final int selectedIndex; + final void Function(int index) onSelected; + final List items; + + @override + Widget build(BuildContext context) { + final List buttons = List.generate( + items.length, + (index) => NavbarItem( + item: items[index], + active: index == selectedIndex, + onTap: () => onSelected(index), + ), + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: buttons, + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/navbar_item.dart b/filcnaplo_mobile_ui/lib/screens/navigation/navbar_item.dart new file mode 100755 index 0000000..b8d490e --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/navbar_item.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class NavItem { + final String title; + final Widget icon; + final Widget activeIcon; + + const NavItem({required this.title, required this.icon, required this.activeIcon}); +} + +class NavbarItem extends StatelessWidget { + const NavbarItem({ + Key? key, + required this.item, + required this.active, + required this.onTap, + }) : super(key: key); + + final NavItem item; + final bool active; + final void Function() onTap; + + @override + Widget build(BuildContext context) { + final Widget icon = active ? item.activeIcon : item.icon; + + return SafeArea( + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 6.0), + child: Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: active ? Theme.of(context).colorScheme.secondary.withOpacity(.4) : null, + borderRadius: BorderRadius.circular(14.0), + ), + child: Stack( + children: [ + IconTheme( + data: IconThemeData( + color: Theme.of(context).colorScheme.secondary, + ), + child: icon, + ), + IconTheme( + data: IconThemeData( + color: Theme.of(context).brightness == Brightness.light ? Colors.black.withOpacity(.5) : Colors.white.withOpacity(.3), + ), + child: icon, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route.dart b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route.dart new file mode 100755 index 0000000..79778aa --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route.dart @@ -0,0 +1,25 @@ +class NavigationRoute { + late String _name; + late int _index; + + final List _internalPageMap = [ + "home", + "grades", + "timetable", + "messages", + "absences", + ]; + + String get name => _name; + int get index => _index; + + set name(String n) { + _name = n; + _index = _internalPageMap.indexOf(n); + } + + set index(int i) { + _index = i; + _name = _internalPageMap.elementAt(i); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route_handler.dart b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route_handler.dart new file mode 100755 index 0000000..1d4db00 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_route_handler.dart @@ -0,0 +1,38 @@ +import 'package:filcnaplo_mobile_ui/pages/absences/absences_page.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grades_page.dart'; +import 'package:filcnaplo_mobile_ui/pages/home/home_page.dart'; +import 'package:filcnaplo_mobile_ui/pages/messages/messages_page.dart'; +import 'package:filcnaplo_mobile_ui/pages/timetable/timetable_page.dart'; +import 'package:flutter/material.dart'; +import 'package:animations/animations.dart'; + +Route navigationRouteHandler(RouteSettings settings) { + switch (settings.name) { + case "home": + return navigationPageRoute((context) => const HomePage()); + case "grades": + return navigationPageRoute((context) => const GradesPage()); + case "timetable": + return navigationPageRoute((context) => const TimetablePage()); + case "messages": + return navigationPageRoute((context) => const MessagesPage()); + case "absences": + return navigationPageRoute((context) => const AbsencesPage()); + default: + return navigationPageRoute((context) => const HomePage()); + } +} + +Route navigationPageRoute(Widget Function(BuildContext) builder) { + return PageRouteBuilder( + pageBuilder: (context, _, __) => builder(context), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeThroughTransition( + fillColor: Theme.of(context).scaffoldBackgroundColor, + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + }, + ); +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/navigation_screen.dart b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_screen.dart new file mode 100755 index 0000000..f69adb0 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/navigation_screen.dart @@ -0,0 +1,302 @@ +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo/helpers/quick_actions.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/observer.dart'; +import 'package:filcnaplo_kreta_api/client/client.dart'; +import 'package:filcnaplo_mobile_ui/common/system_chrome.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/nabar.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navbar_item.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navigation_route.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/navigation_route_handler.dart'; +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/screens/navigation/status_bar.dart'; +import 'package:filcnaplo_mobile_ui/screens/news/news_view.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo_mobile_ui/common/screens.i18n.dart'; +import 'package:filcnaplo/api/providers/news_provider.dart'; +import 'package:filcnaplo/api/providers/sync.dart'; +import 'package:home_widget/home_widget.dart'; +import 'package:sliding_sheet/sliding_sheet.dart'; +import 'package:background_fetch/background_fetch.dart'; + +class NavigationScreen extends StatefulWidget { + const NavigationScreen({Key? key}) : super(key: key); + + static NavigationScreenState? of(BuildContext context) => context.findAncestorStateOfType(); + + @override + NavigationScreenState createState() => NavigationScreenState(); +} + +class NavigationScreenState extends State with WidgetsBindingObserver { + late NavigationRoute selected; + List initializers = []; + final _navigatorState = GlobalKey(); + + late SettingsProvider settings; + late NewsProvider newsProvider; + late UpdateProvider updateProvider; + + NavigatorState? get navigator => _navigatorState.currentState; + + void customRoute(Route route) => navigator?.pushReplacement(route); + + bool init(String id) { + if (initializers.contains(id)) return false; + + initializers.add(id); + + return true; + } + + void _checkForWidgetLaunch() { + HomeWidget.initiallyLaunchedFromHomeWidget().then(_launchedFromWidget); + } + + void _launchedFromWidget(Uri? uri) async { + if (uri == null) return; + + if (uri.scheme == "timetable" && uri.authority == "refresh") { + Navigator.of(context).popUntil((route) => route.isFirst); + + setPage("timetable"); + _navigatorState.currentState?.pushNamedAndRemoveUntil("timetable", (_) => false); + } else if (uri.scheme == "settings" && uri.authority == "premium") { + Navigator.of(context).popUntil((route) => route.isFirst); + + showSlidingBottomSheet( + context, + useRootNavigator: true, + builder: (context) => SlidingSheetDialog( + color: Theme.of(context).scaffoldBackgroundColor, + duration: const Duration(milliseconds: 400), + scrollSpec: const ScrollSpec.bouncingScroll(), + snapSpec: const SnapSpec( + snap: true, + snappings: [1.0], + positioning: SnapPositioning.relativeToSheetHeight, + ), + cornerRadius: 16, + cornerRadiusOnFullscreen: 0, + builder: (context, state) => Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: const SettingsScreen(), + ), + ), + ); + } + } + + // Platform messages are asynchronous, so we initialize in an async method. + Future initPlatformState() async { + // Configure BackgroundFetch. + int status = await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + stopOnTerminate: false, + enableHeadless: true, + requiresBatteryNotLow: false, + requiresCharging: false, + requiresStorageNotLow: false, + requiresDeviceIdle: false, + requiredNetworkType: NetworkType.ANY), (String taskId) async { + // <-- Event handler + // This is the fetch-event callback. + print("[BackgroundFetch] Event received $taskId"); + + // IMPORTANT: You must signal completion of your task or the OS can punish your app + // for taking too long in the background. + BackgroundFetch.finish(taskId); + }, (String taskId) async { + // <-- Task timeout handler. + // This task has exceeded its allowed running-time. You must stop what you're doing and immediately .finish(taskId) + print("[BackgroundFetch] TASK TIMEOUT taskId: $taskId"); + BackgroundFetch.finish(taskId); + }); + print('[BackgroundFetch] configure success: $status'); + + // If the widget was removed from the tree while the asynchronous platform + // message was in flight, we want to discard the reply rather than calling + // setState to update our non-existent appearance. + if (!mounted) return; + } + + @override + void initState() { + super.initState(); + + initPlatformState(); + + HomeWidget.setAppGroupId('hu.filc.naplo.group'); + + _checkForWidgetLaunch(); + HomeWidget.widgetClicked.listen(_launchedFromWidget); + + settings = Provider.of(context, listen: false); + selected = NavigationRoute(); + selected.index = settings.startPage.index; // set page index to start page + + // add brightness observer + WidgetsBinding.instance.addObserver(this); + + // set client User-Agent + Provider.of(context, listen: false).userAgent = settings.config.userAgent; + + // Get news + newsProvider = Provider.of(context, listen: false); + newsProvider.restore().then((value) => newsProvider.fetch()); + + // Get releases + updateProvider = Provider.of(context, listen: false); + updateProvider.fetch(); + + // Initial sync + syncAll(context); + setupQuickActions(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + if (settings.theme == ThemeMode.system) { + Brightness? brightness = WidgetsBinding.instance.window.platformBrightness; + Provider.of(context, listen: false).changeTheme(brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark); + } + super.didChangePlatformBrightness(); + } + + void setPage(String page) => setState(() => selected.name = page); + + @override + Widget build(BuildContext context) { + setSystemChrome(context); + settings = Provider.of(context); + newsProvider = Provider.of(context); + + // Show news + WidgetsBinding.instance.addPostFrameCallback((_) { + if (newsProvider.show) { + newsProvider.lock(); + NewsView.show(newsProvider.news[newsProvider.state], context: context).then((value) => newsProvider.release()); + } + }); + + handleQuickActions(context, (page) { + setPage(page); + _navigatorState.currentState?.pushReplacementNamed(page); + }); + + return WillPopScope( + onWillPop: () async { + if (_navigatorState.currentState?.canPop() ?? false) { + _navigatorState.currentState?.pop(); + if (!kDebugMode) { + return true; + } + return false; + } + + if (selected.index != 0) { + setState(() => selected.index = 0); + _navigatorState.currentState?.pushReplacementNamed(selected.name); + } + + return false; + }, + child: Scaffold( + body: Column( + children: [ + Expanded( + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + Navigator( + key: _navigatorState, + initialRoute: selected.name, + onGenerateRoute: (settings) => navigationRouteHandler(settings), + ), + ], + ), + ), + + // Status bar + Material( + color: Theme.of(context).colorScheme.background, + child: const StatusBar(), + ), + + // Bottom Navigaton Bar + Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: Navbar( + selectedIndex: selected.index, + onSelected: onPageSelected, + items: [ + NavItem( + title: "home".i18n, + icon: const Icon(FilcIcons.home), + activeIcon: const Icon(FilcIcons.homefill), + ), + NavItem( + title: "grades".i18n, + icon: const Icon(FeatherIcons.bookmark), + activeIcon: const Icon(FilcIcons.gradesfill), + ), + NavItem( + title: "timetable".i18n, + icon: const Icon(FeatherIcons.calendar), + activeIcon: const Icon(FilcIcons.timetablefill), + ), + NavItem( + title: "messages".i18n, + icon: const Icon(FeatherIcons.messageSquare), + activeIcon: const Icon(FilcIcons.messagesfill), + ), + NavItem( + title: "absences".i18n, + icon: const Icon(FeatherIcons.clock), + activeIcon: const Icon(FilcIcons.absencesfill), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void onPageSelected(int index) { + // Vibrate, then set the active screen + if (selected.index != index) { + switch (settings.vibrate) { + case VibrationStrength.light: + HapticFeedback.lightImpact(); + break; + case VibrationStrength.medium: + HapticFeedback.mediumImpact(); + break; + case VibrationStrength.strong: + HapticFeedback.heavyImpact(); + break; + default: + } + setState(() => selected.index = index); + _navigatorState.currentState?.pushReplacementNamed(selected.name); + } + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.dart b/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.dart new file mode 100755 index 0000000..c845be6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.dart @@ -0,0 +1,110 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/api/providers/status_provider.dart'; +import 'status_bar.i18n.dart'; + +class StatusBar extends StatefulWidget { + const StatusBar({Key? key}) : super(key: key); + + @override + _StatusBarState createState() => _StatusBarState(); +} + +class _StatusBarState extends State { + late StatusProvider statusProvider; + + @override + Widget build(BuildContext context) { + statusProvider = Provider.of(context); + + Status? currentStatus = statusProvider.getStatus(); + Color backgroundColor = _statusColor(currentStatus); + Color color = ColorUtils.foregroundColor(backgroundColor); + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + height: currentStatus != null ? 32.0 : 0, + width: double.infinity, + color: Theme.of(context).scaffoldBackgroundColor, + child: Stack( + children: [ + // Background + AnimatedContainer( + margin: const EdgeInsets.only(left: 6.0, right: 6.0, top: 8.0), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + height: currentStatus != null ? 28.0 : 0, + decoration: BoxDecoration( + color: backgroundColor, + boxShadow: [BoxShadow(color: Theme.of(context).shadowColor, blurRadius: 8.0)], + borderRadius: BorderRadius.circular(45.0), + ), + ), + + // Progress bar + if (currentStatus == Status.syncing) + Container( + margin: const EdgeInsets.only(left: 6.0, right: 6.0, top: 8.0), + alignment: Alignment.bottomLeft, + child: AnimatedContainer( + height: currentStatus != null ? 28.0 : 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + width: MediaQuery.of(context).size.width * statusProvider.progress - 16.0, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity(0.8), + borderRadius: BorderRadius.circular(45.0), + ), + ), + ), + + // Text + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Center( + child: Text( + _statusString(currentStatus), + style: TextStyle(color: color, fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } + + String _statusString(Status? status) { + switch (status) { + case Status.syncing: + return "Syncing data".i18n; + case Status.maintenance: + return "KRETA Maintenance".i18n; + case Status.network: + return "No connection".i18n; + default: + return ""; + } + } + + Color _statusColor(Status? status) { + switch (status) { + case Status.maintenance: + return AppColors.of(context).red; + case Status.network: + case Status.syncing: + default: + HSLColor color = HSLColor.fromColor(Theme.of(context).scaffoldBackgroundColor); + if (color.lightness >= 0.5) { + color = color.withSaturation(0.3); + color = color.withLightness(color.lightness - 0.1); + } else { + color = color.withSaturation(0); + color = color.withLightness(color.lightness + 0.2); + } + return color.toColor(); + } + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.i18n.dart b/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.i18n.dart new file mode 100755 index 0000000..a13dbdf --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/navigation/status_bar.i18n.dart @@ -0,0 +1,27 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "Syncing data": "Syncing data", + "KRETA Maintenance": "KRETA Maintenance", + "No connection": "No connection", + }, + "hu_hu": { + "Syncing data": "Adatok frissítése", + "KRETA Maintenance": "KRÉTA Karbantartás", + "No connection": "Nincs kapcsolat", + }, + "de_de": { + "Syncing data": "Daten aktualisieren", + "KRETA Maintenance": "KRETA Wartung", + "No connection": "Keine Verbindung", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/screens/news/news_screen.dart b/filcnaplo_mobile_ui/lib/screens/news/news_screen.dart new file mode 100755 index 0000000..87975be --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/news/news_screen.dart @@ -0,0 +1,61 @@ +import 'dart:math'; + +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/screens/news/news_tile.dart'; +import 'package:filcnaplo/models/news.dart'; +import 'package:filcnaplo_mobile_ui/screens/news/news_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/api/providers/news_provider.dart'; + +class NewsScreen extends StatelessWidget { + const NewsScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + var newsProvider = Provider.of(context); + + List news = []; + news = newsProvider.news.where((e) => e.title != "").toList(); + + return Scaffold( + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text("News", style: TextStyle(color: AppColors.of(context).text)), + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: () => newsProvider.fetch(), + child: ListView.builder( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + itemCount: max(news.length, 1), + itemBuilder: (context, index) { + if (news.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), + child: Panel( + child: Material( + type: MaterialType.transparency, + child: NewsTile( + news[index], + onTap: () => NewsView.show(news[index], context: context, force: true), + ), + ), + ), + ); + } else { + return const Padding( + padding: EdgeInsets.only(top: 24.0), + child: Empty(subtitle: "Nothing to see here"), + ); + } + }, + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/news/news_tile.dart b/filcnaplo_mobile_ui/lib/screens/news/news_tile.dart new file mode 100755 index 0000000..f562b63 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/news/news_tile.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:filcnaplo/models/news.dart'; +import 'package:filcnaplo/utils/format.dart'; + +class NewsTile extends StatelessWidget { + const NewsTile(this.news, {Key? key, this.onTap}) : super(key: key); + + final News news; + final Function()? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + news.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + news.content.escapeHtml().replaceAll("\n", " "), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onTap: onTap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/news/news_view.dart b/filcnaplo_mobile_ui/lib/screens/news/news_view.dart new file mode 100755 index 0000000..25e0491 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/news/news_view.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_mobile_ui/common/dialog_button.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/models/news.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; + +class NewsView extends StatelessWidget { + const NewsView(this.news, {Key? key}) : super(key: key); + + final News news; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 100.0, horizontal: 32.0), + child: Material( + borderRadius: BorderRadius.circular(12.0), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Column( + children: [ + // Content + Expanded( + child: ListView( + physics: const BouncingScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, right: 6.0, top: 14.0, bottom: 8.0), + child: Text( + news.title, + maxLines: 3, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18.0), + ), + ), + SelectableLinkify( + text: news.content.escapeHtml(), + options: const LinkifyOptions(looseUrl: true, removeWww: true), + onOpen: (link) { + launch( + link.url, + customTabsOption: CustomTabsOption(showPageTitle: true, toolbarColor: Theme.of(context).scaffoldBackgroundColor), + ); + }, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14.0), + ), + ], + ), + ), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (news.link != "") + DialogButton( + label: news.openLabel != "" ? news.openLabel : "open".i18n.toUpperCase(), + onTap: () => launch( + news.link, + customTabsOption: CustomTabsOption(showPageTitle: true, toolbarColor: Theme.of(context).scaffoldBackgroundColor), + ), + ), + DialogButton( + label: "done".i18n, + onTap: () => Navigator.of(context).maybePop(), + ), + ], + ), + ], + ), + ), + ), + ); + } + + static Future show(News news, {required BuildContext context, bool force = false}) { + if (news.title == "") return Future.value(null); + + bool popup = news.platform == '' || force; + + if (Provider.of(context, listen: false).newsEnabled || news.emergency || force) { + switch (news.platform.trim().toLowerCase()) { + case "android": + if (Platform.isAndroid) popup = true; + break; + case "ios": + if (Platform.isIOS) popup = true; + break; + case "linux": + if (Platform.isLinux) popup = true; + break; + case "windows": + if (Platform.isWindows) popup = true; + break; + case "macos": + if (Platform.isMacOS) popup = true; + break; + default: + popup = true; + } + } else { + popup = false; + } + + if (popup) { + return showDialog(context: context, builder: (context) => NewsView(news), barrierDismissible: true); + } else { + return Future.value(null); + } + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_tile.dart b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_tile.dart new file mode 100755 index 0000000..e9eb96a --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_tile.dart @@ -0,0 +1,40 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; + +class AccountTile extends StatelessWidget { + const AccountTile({Key? key, this.onTap, this.onTapMenu, this.profileImage, this.name, this.username}) : super(key: key); + + final void Function()? onTap; + final void Function()? onTapMenu; + final Widget? profileImage; + final Widget? name; + final Widget? username; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: ListTile( + visualDensity: VisualDensity.compact, + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + onTap: onTap, + onLongPress: onTapMenu, + leading: profileImage, + title: name, + subtitle: username, + trailing: onTapMenu != null + ? Material( + color: Colors.transparent, + child: IconButton( + splashRadius: 24.0, + onPressed: onTapMenu, + icon: Icon(FeatherIcons.moreVertical, color: AppColors.of(context).text.withOpacity(0.8)), + ), + ) + : null, + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.dart b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.dart new file mode 100755 index 0000000..88e526d --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.dart @@ -0,0 +1,54 @@ +import 'package:filcnaplo/models/user.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_card.dart'; +import 'package:filcnaplo_mobile_ui/common/detail.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'account_view.i18n.dart'; + +class AccountView extends StatelessWidget { + const AccountView(this.user, {Key? key}) : super(key: key); + + final User user; + + static void show(User user, {required BuildContext context}) => showBottomCard(context: context, child: AccountView(user)); + + @override + Widget build(BuildContext context) { + List _nameParts = user.name.split(" "); + String _firstName = _nameParts.length > 1 ? _nameParts[1] : _nameParts[0]; + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AccountTile( + profileImage: ProfileImage( + name: _firstName, + backgroundColor: ColorUtils.stringToColor(user.name), + role: user.role, + ), + name: SelectableText( + user.name, + style: const TextStyle(fontWeight: FontWeight.w500), + maxLines: 2, + minLines: 1, + ), + username: SelectableText(user.username), + ), + + // User details + Detail(title: "birthdate".i18n, description: DateFormat("yyyy. MM. dd.").format(user.student.birth)), + Detail(title: "school".i18n, description: user.student.school.name), + if (user.student.className != null) Detail(title: "class".i18n, description: user.student.className!), + if (user.student.address != null) Detail(title: "address".i18n, description: user.student.address!), + if (user.student.parents.isNotEmpty) + Detail(title: "parents".plural(user.student.parents.length), description: user.student.parents.join(", ")), + ], + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.i18n.dart b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.i18n.dart new file mode 100755 index 0000000..7dd4471 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/accounts/account_view.i18n.dart @@ -0,0 +1,33 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension Localization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "birthdate": "Birth date", + "school": "School", + "class": "Class", + "address": "Home address", + "parents": "Parents".one("Parent"), + }, + "hu_hu": { + "birthdate": "Születési dátum", + "school": "Iskola", + "class": "Osztály", + "address": "Lakcím", + "parents": "Szülők".one("Szülő"), + }, + "de_de": { + "birthdate": "Geburtsdatum", + "school": "Schule", + "class": "Klasse", + "address": "Wohnanschrift", + "parents": "Eltern", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/debug/subject_icon_gallery.dart b/filcnaplo_mobile_ui/lib/screens/settings/debug/subject_icon_gallery.dart new file mode 100755 index 0000000..0a2e8f4 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/debug/subject_icon_gallery.dart @@ -0,0 +1,81 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/material.dart'; + +class SubjectIconGallery extends StatelessWidget { + const SubjectIconGallery({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text( + "Subject Icon Gallery", + style: TextStyle(color: AppColors.of(context).text), + ), + ), + body: ListView( + children: const [ + SubjectIconItem("Matematika"), + SubjectIconItem("Magyar Nyelv"), + SubjectIconItem("Nyelvtan"), + SubjectIconItem("Irodalom"), + SubjectIconItem("Történelem"), + SubjectIconItem("Földrajz"), + SubjectIconItem("Rajz"), + SubjectIconItem("Vizuális kultúra"), + SubjectIconItem("Fizika"), + SubjectIconItem("Ének"), + SubjectIconItem("Testnevelés"), + SubjectIconItem("Kémia"), + SubjectIconItem("Biológia"), + SubjectIconItem("Természetismeret"), + SubjectIconItem("Erkölcstan"), + SubjectIconItem("Pénzügy"), + SubjectIconItem("Informatika"), + SubjectIconItem("Digitális kultúra"), + SubjectIconItem("Programozás"), + SubjectIconItem("Hálózat"), + SubjectIconItem("Színház technika"), + SubjectIconItem("Média"), + SubjectIconItem("Elektronika"), + SubjectIconItem("Gépészet"), + SubjectIconItem("Technika"), + SubjectIconItem("Tánc"), + SubjectIconItem("Filozófia"), + SubjectIconItem("Osztályfőnöki"), + SubjectIconItem("Gazdaság"), + SubjectIconItem("Szorgalom"), + SubjectIconItem("Magatartás"), + SubjectIconItem("Angol nyelv"), + SubjectIconItem("Linux"), + ], + ), + ); + } +} + +class SubjectIconItem extends StatelessWidget { + const SubjectIconItem(this.name, {Key? key}) : super(key: key); + + final String name; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon( + SubjectIcon.resolveVariant(subjectName: name, context: context), + color: AppColors.of(context).text, + ), + title: Text( + name, + style: TextStyle( + color: AppColors.of(context).text, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/privacy_view.dart b/filcnaplo_mobile_ui/lib/screens/settings/privacy_view.dart new file mode 100755 index 0000000..e7851d6 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/privacy_view.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'settings_screen.i18n.dart'; + +class PrivacyView extends StatelessWidget { + const PrivacyView({Key? key}) : super(key: key); + + static void show(BuildContext context) => showDialog(context: context, builder: (context) => const PrivacyView(), barrierDismissible: true); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 100.0, horizontal: 32.0), + child: Material( + borderRadius: BorderRadius.circular(12.0), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: ListView( + physics: const BouncingScrollPhysics(), + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text("privacy".i18n), + ), + SelectableLinkify( + text: """ +A Filc Napló egy kliensalkalmazás, segítségével az e-Kréta rendszeréből letöltheted és felhasználóbarát módon megjelenítheted az adataidat. +Tanulmányi adataid csak közvetlenül az alkalmazás és a Kréta-szerverek között közlekednek, titkosított kapcsolaton keresztül. + +A Filc fejlesztői és üzemeltetői a tanulmányi adataidat semmilyen célból nem másolják, nem tárolják és harmadik félnek nem továbbítják. Ezeket így az e-Kréta Informatikai Zrt. kezeli, az ő tájékoztatójukat itt találod: https://tudasbazis.ekreta.hu/pages/viewpage.action?pageId=4065038. +Azok törlésével vagy módosítával kapcsolatban keresd az osztályfőnöködet vagy az iskolád rendszergazdáját. + +Az alkalmazás névtelen használati statisztikákat gyűjt, ezek alapján tudjuk meghatározni a felhasználók és a telepítések számát. Ezt a beállításokban kikapcsolhatod. +Kérünk, hogy ha csak teheted, hagyd ezt a funkciót bekapcsolva. + +Amikor az alkalmazás hibába ütközik, lehetőség van hibajelentés küldésére. +Ez személyes- vagy tanulmányi adatokat nem tartalmaz, viszont részletes információval szolgál a hibáról és eszközödről. +A küldés előtt megjelenő képernyőn a te felelősséged átnézni a továbbításra kerülő adatsort. +A hibajelentéseket a Filc fejlesztői felületén és egy privát Discord szobában tároljuk, ezekhez csak az app fejlesztői férnek hozzá. +Az alkalmazás belépéskor a GitHub API segítségével ellenőrzi, hogy elérhető-e új verzió, és kérésre innen is tölti le a telepítőt. + +Ha az adataiddal kapcsolatban bármilyen kérdésed van (törlés, módosítás, adathordozás), keress minket a filcnaplo@filcnaplo.hu címen. + +Az alkalmazás használatával jelzed, hogy ezt a tájékoztatót tudomásul vetted. + +Utolsó módosítás: 2021. 09. 25. + """, + onOpen: (link) => launch(link.url, + customTabsOption: CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )), + ), + ], + ), + ), + ), + ); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/settings_helper.dart b/filcnaplo_mobile_ui/lib/screens/settings/settings_helper.dart new file mode 100755 index 0000000..82c2e85 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/settings_helper.dart @@ -0,0 +1,548 @@ +// ignore_for_file: prefer_function_declarations_over_variables + +import 'dart:io'; + +import 'package:filcnaplo/helpers/quick_actions.dart'; +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/theme/observer.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/week.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu_item.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart'; +import 'package:filcnaplo_mobile_ui/common/filter_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/material_action_button.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:i18n_extension/i18n_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo_mobile_ui/common/screens.i18n.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; +import 'package:flutter_material_color_picker/flutter_material_color_picker.dart'; +import 'package:filcnaplo/models/icon_pack.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/theme.dart'; + +class SettingsHelper { + static const Map langMap = {"en": "🇬🇧 English", "hu": "🇭🇺 Magyar", "de": "🇩🇪 Deutsch"}; + + static const Map pageTitle = { + Pages.home: "home", + Pages.grades: "grades", + Pages.timetable: "timetable", + Pages.messages: "messages", + Pages.absences: "absences", + }; + + static Map vibrationTitle = { + VibrationStrength.off: "voff", + VibrationStrength.light: "vlight", + VibrationStrength.medium: "vmedium", + VibrationStrength.strong: "vstrong", + }; + + static Map localizedPageTitles() => pageTitle.map((key, value) => MapEntry(key, ScreensLocalization(value).i18n)); + static Map localizedVibrationTitles() => + vibrationTitle.map((key, value) => MapEntry(key, SettingsLocalization(value).i18n)); + + static void language(BuildContext context) { + showBottomSheetMenu( + context, + items: List.generate(langMap.length, (index) { + String lang = langMap.keys.toList()[index]; + return BottomSheetMenuItem( + onPressed: () { + Provider.of(context, listen: false).update(language: lang); + I18n.of(context).locale = Locale(lang, lang.toUpperCase()); + Navigator.of(context).maybePop(); + if (Platform.isAndroid || Platform.isIOS) { + setupQuickActions(); + } + }, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(langMap.values.toList()[index]), + if (lang == I18n.of(context).locale.languageCode) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ); + }), + ); + } + + static void iconPack(BuildContext context) { + final settings = Provider.of(context, listen: false); + showBottomSheetMenu( + context, + items: List.generate(IconPack.values.length, (index) { + IconPack current = IconPack.values[index]; + return BottomSheetMenuItem( + onPressed: () { + settings.update(iconPack: current); + Navigator.of(context).maybePop(); + }, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(current.name.capital()), + if (current == settings.iconPack) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ); + }), + ); + } + + static void startPage(BuildContext context) { + Map pageIcons = { + Pages.home: FilcIcons.home, + Pages.grades: FeatherIcons.bookmark, + Pages.timetable: FeatherIcons.calendar, + Pages.messages: FeatherIcons.messageSquare, + Pages.absences: FeatherIcons.clock, + }; + + showBottomSheetMenu( + context, + items: List.generate(Pages.values.length, (index) { + return BottomSheetMenuItem( + onPressed: () { + Provider.of(context, listen: false).update(startPage: Pages.values[index]); + Navigator.of(context).maybePop(); + }, + title: Row( + children: [ + Icon(pageIcons[Pages.values[index]], size: 20.0, color: Theme.of(context).colorScheme.secondary), + const SizedBox(width: 16.0), + Text(localizedPageTitles()[Pages.values[index]] ?? ""), + const Spacer(), + if (Pages.values[index] == Provider.of(context, listen: false).startPage) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ); + }), + ); + } + + static void rounding(BuildContext context) { + showRoundedModalBottomSheet( + context, + child: const RoundingSetting(), + ); + } + + static void theme(BuildContext context) { + var settings = Provider.of(context, listen: false); + void Function(ThemeMode) setTheme = (mode) { + settings.update(theme: mode); + Provider.of(context, listen: false).changeTheme(mode); + Navigator.of(context).maybePop(); + }; + + showBottomSheetMenu(context, items: [ + BottomSheetMenuItem( + onPressed: () => setTheme(ThemeMode.system), + title: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon(FeatherIcons.smartphone, size: 20.0, color: Theme.of(context).colorScheme.secondary), + ), + Text(SettingsLocalization("system").i18n), + const Spacer(), + if (settings.theme == ThemeMode.system) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ), + BottomSheetMenuItem( + onPressed: () => setTheme(ThemeMode.light), + title: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon(FeatherIcons.sun, size: 20.0, color: Theme.of(context).colorScheme.secondary), + ), + Text(SettingsLocalization("light").i18n), + const Spacer(), + if (settings.theme == ThemeMode.light) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ), + BottomSheetMenuItem( + onPressed: () => setTheme(ThemeMode.dark), + title: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon(FeatherIcons.moon, size: 20.0, color: Theme.of(context).colorScheme.secondary), + ), + Text(SettingsLocalization("dark").i18n), + const Spacer(), + if (settings.theme == ThemeMode.dark) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ) + ]); + } + + static void accentColor(BuildContext context) { + Navigator.of(context, rootNavigator: true).push( + PageRouteBuilder( + pageBuilder: (context, _, __) => const PremiumCustomAccentColorSetting(), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ), + ); + } + + static void gradeColors(BuildContext context) { + showRoundedModalBottomSheet( + context, + child: const GradeColorsSetting(), + ); + } + + static void vibrate(BuildContext context) { + showBottomSheetMenu( + context, + items: List.generate(VibrationStrength.values.length, (index) { + VibrationStrength value = VibrationStrength.values[index]; + + return BottomSheetMenuItem( + onPressed: () { + Provider.of(context, listen: false).update(vibrate: value); + Navigator.of(context).maybePop(); + }, + title: Row( + children: [ + Container( + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary.withOpacity((index + 1) / (vibrationTitle.length + 1)), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 16.0), + Text(localizedVibrationTitles()[value] ?? "?"), + const Spacer(), + if (value == Provider.of(context, listen: false).vibrate) + Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.secondary, + ), + ], + ), + ); + }), + ); + } + + static void bellDelay(BuildContext context) { + showRoundedModalBottomSheet( + context, + child: const BellDelaySetting(), + ); + } +} + +// Rounding modal +class RoundingSetting extends StatefulWidget { + const RoundingSetting({Key? key}) : super(key: key); + + @override + _RoundingSettingState createState() => _RoundingSettingState(); +} + +class _RoundingSettingState extends State { + late double rounding; + + @override + void initState() { + super.initState(); + rounding = Provider.of(context, listen: false).rounding / 10; + } + + @override + Widget build(BuildContext context) { + int roundingResult; + + if (4.5 >= 4.5.floor() + rounding) { + roundingResult = 5; + } else { + roundingResult = 4; + } + + return Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Slider( + value: rounding, + min: 0.1, + max: 0.9, + divisions: 8, + label: rounding.toStringAsFixed(1), + activeColor: Theme.of(context).colorScheme.secondary, + thumbColor: Theme.of(context).colorScheme.secondary, + onChanged: (v) => setState(() => rounding = v), + ), + ), + Container( + width: 50.0, + padding: const EdgeInsets.only(right: 16.0), + child: Center( + child: Text(rounding.toStringAsFixed(1), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18.0, + )), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("4.5", style: TextStyle(fontSize: 26.0, fontWeight: FontWeight.w500)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: Icon(FeatherIcons.arrowRight, color: Colors.grey), + ), + GradeValueWidget(GradeValue(roundingResult, "", "", 100), fill: true, size: 32.0), + ], + ), + Padding( + padding: const EdgeInsets.only(bottom: 12.0, top: 6.0), + child: MaterialActionButton( + child: Text(SettingsLocalization("done").i18n), + onPressed: () { + Provider.of(context, listen: false).update(rounding: (rounding * 10).toInt()); + Navigator.of(context).maybePop(); + }, + ), + ), + ]); + } +} + +// Bell Delay Modal + +class BellDelaySetting extends StatefulWidget { + const BellDelaySetting({Key? key}) : super(key: key); + + @override + State createState() => _BellDelaySettingState(); +} + +class _BellDelaySettingState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + late Duration currentDelay; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this, initialIndex: Provider.of(context, listen: false).bellDelay > 0 ? 1 : 0); + currentDelay = Duration(seconds: Provider.of(context, listen: false).bellDelay); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + FilterBar( + scrollable: false, + items: [ + Tab(text: SettingsLocalization("delay").i18n), + Tab(text: SettingsLocalization("hurry").i18n), + ], + controller: _tabController, + onTap: (i) async { + // swap current page with target page + setState(() { + currentDelay = i == 0 ? -currentDelay.abs() : currentDelay.abs(); + }); + }, + ), + SizedBox( + height: 200, + child: CupertinoTheme( + data: CupertinoThemeData( + brightness: Theme.of(context).brightness, + ), + child: CupertinoTimerPicker( + key: UniqueKey(), + mode: CupertinoTimerPickerMode.ms, + initialTimerDuration: currentDelay.abs(), + onTimerDurationChanged: (Duration d) { + HapticFeedback.selectionClick(); + + currentDelay = _tabController.index == 0 ? -d : d; + }, + ), + ), + ), + Text(SettingsLocalization("sync_help").i18n, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12.0, fontWeight: FontWeight.w500, color: AppColors.of(context).text.withOpacity(.75))), + Padding( + padding: const EdgeInsets.only(bottom: 12.0, top: 6.0), + child: Column( + children: [ + MaterialActionButton( + backgroundColor: AppColors.of(context).filc, + child: Text(SettingsLocalization("sync").i18n), + onPressed: () { + final lessonProvider = Provider.of(context, listen: false); + + Duration? closest; + DateTime now = DateTime.now(); + for (var lesson in lessonProvider.getWeek(Week.current()) ?? []) { + Duration sdiff = lesson.start.difference(now); + Duration ediff = lesson.end.difference(now); + + if (closest == null || sdiff.abs() < closest.abs()) closest = sdiff; + if (ediff.abs() < closest.abs()) closest = ediff; + } + if (closest != null) { + if (closest.inHours.abs() >= 1) return; + currentDelay = closest; + Provider.of(context, listen: false).update(bellDelay: currentDelay.inSeconds); + _tabController.index = currentDelay.inSeconds > 0 ? 1 : 0; + setState(() {}); + } + }, + ), + MaterialActionButton( + child: Text(SettingsLocalization("done").i18n), + onPressed: () { + //Provider.of(context, listen: false).update(context, rounding: (r * 10).toInt()); + Provider.of(context, listen: false).update(bellDelay: currentDelay.inSeconds); + Navigator.of(context).maybePop(); + }, + ), + ], + ), + ), + ], + ); + } +} + +class GradeColorsSetting extends StatefulWidget { + const GradeColorsSetting({Key? key}) : super(key: key); + + @override + _GradeColorsSettingState createState() => _GradeColorsSettingState(); +} + +class _GradeColorsSettingState extends State { + Color currentColor = const Color(0x00000000); + late SettingsProvider settings; + + @override + void initState() { + super.initState(); + settings = Provider.of(context, listen: false); + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(5, (index) { + return ClipOval( + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + currentColor = settings.gradeColors[index]; + showRoundedModalBottomSheet( + context, + child: Column(children: [ + MaterialColorPicker( + selectedColor: settings.gradeColors[index], + onColorChange: (v) { + setState(() { + currentColor = v; + }); + }, + allowShades: true, + elevation: 0, + physics: const NeverScrollableScrollPhysics(), + ), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + MaterialActionButton( + onPressed: () { + List colors = List.castFrom(settings.gradeColors); + var defaultColors = SettingsProvider.defaultSettings().gradeColors; + colors[index] = defaultColors[index]; + settings.update(gradeColors: colors); + Navigator.of(context).maybePop(); + }, + child: Text(SettingsLocalization("reset").i18n), + ), + MaterialActionButton( + onPressed: () { + List colors = List.castFrom(settings.gradeColors); + colors[index] = currentColor.withAlpha(255); + settings.update(gradeColors: settings.gradeColors); + Navigator.of(context).maybePop(); + }, + child: Text(SettingsLocalization("done").i18n), + ), + ], + ), + ), + ]), + ).then((value) => setState(() {})); + }, + child: GradeValueWidget(GradeValue(index + 1, "", "", 0), fill: true, size: 36.0), + ), + ), + ); + }), + ), + ), + ]); + } +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/settings_route.dart b/filcnaplo_mobile_ui/lib/screens/settings/settings_route.dart new file mode 100755 index 0000000..70195e8 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/settings_route.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +Route settingsRoute(Widget widget) { + return PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => widget, + transitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + var curve = Curves.ease; + var curveTween = CurveTween(curve: curve); + var begin = const Offset(0.0, 1.0); + var end = Offset.zero; + var tween = Tween(begin: begin, end: end).chain(curveTween); + var offsetAnimation = animation.drive(tween); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + ); +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.dart b/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.dart new file mode 100755 index 0000000..917de8b --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.dart @@ -0,0 +1,816 @@ +import 'package:filcnaplo/api/providers/update_provider.dart'; +import 'package:filcnaplo/theme/colors/accent.dart'; +import 'package:filcnaplo/theme/observer.dart'; +import 'package:filcnaplo_kreta_api/providers/absence_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/event_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/exam_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/homework_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/message_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/note_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/models/user.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/client/client.dart'; +import 'package:filcnaplo_mobile_ui/common/action_button.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu_item.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart'; +import 'package:filcnaplo_mobile_ui/common/system_chrome.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/update/updates_view.dart'; +import 'package:filcnaplo_mobile_ui/premium/components/active_sponsor_card.dart'; +import 'package:filcnaplo_mobile_ui/premium/premium_button.dart'; +import 'package:filcnaplo_mobile_ui/screens/news/news_screen.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_tile.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_view.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/debug/subject_icon_gallery.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/privacy_view.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_helper.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart' as tabs; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/color.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'settings_screen.i18n.dart'; +import 'package:flutter/services.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/nickname.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/profile_pic.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/icon_pack.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/modify_subject_names.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({Key? key}) : super(key: key); + + @override + _SettingsScreenState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State with SingleTickerProviderStateMixin { + int devmodeCountdown = 3; + bool __ss = false; // secret settings + + late UserProvider user; + late UpdateProvider updateProvider; + late SettingsProvider settings; + late KretaClient kretaClient; + late String firstName; + List accountTiles = []; + + late AnimationController _hideContainersController; + + Future restore() => Future.wait([ + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restoreUser(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).restore(), + Provider.of(context, listen: false).refreshLogin(), + ]); + + void buildAccountTiles() { + accountTiles = []; + user.getUsers().forEach((account) { + if (account.id == user.id) return; + + String _firstName; + + List _nameParts = user.displayName?.split(" ") ?? ["?"]; + if (!settings.presentationMode) { + _firstName = _nameParts.length > 1 ? _nameParts[1] : _nameParts[0]; + } else { + _firstName = "Béla"; + } + + accountTiles.add(AccountTile( + name: Text(!settings.presentationMode ? account.name : "Béla", style: const TextStyle(fontWeight: FontWeight.w500)), + username: Text(!settings.presentationMode ? account.username : "72469696969"), + profileImage: ProfileImage( + name: _firstName, + backgroundColor: !settings.presentationMode ? ColorUtils.stringToColor(account.name) : Theme.of(context).colorScheme.secondary, + role: account.role, + ), + onTap: () { + user.setUser(account.id); + restore().then((_) => user.setUser(account.id)); + Navigator.of(context).pop(); + }, + onTapMenu: () => _showBottomSheet(account), + )); + }); + } + + void _showBottomSheet(User u) { + showBottomSheetMenu(context, items: [ + BottomSheetMenuItem( + onPressed: () => AccountView.show(u, context: context), + icon: const Icon(FeatherIcons.user), + title: Text("personal_details".i18n), + ), + BottomSheetMenuItem( + onPressed: () => _openDKT(u), + icon: Icon(FeatherIcons.grid, color: AppColors.of(context).teal), + title: Text("open_dkt".i18n), + ), + const UserMenuNickname(), + const UserMenuProfilePic(), + // BottomSheetMenuItem( + // onPressed: () {}, + // icon: Icon(FeatherIcons.camera), + // title: Text("edit_profile_picture".i18n), + // ), + // BottomSheetMenuItem( + // onPressed: () {}, + // icon: Icon(FeatherIcons.trash2, color: AppColors.of(context).red), + // title: Text("remove_profile_picture".i18n), + // ), + ]); + } + + void _openDKT(User u) => tabs.launch("https://dkttanulo.e-kreta.hu/sso?id_token=${kretaClient.idToken}", + customTabsOption: tabs.CustomTabsOption( + toolbarColor: Theme.of(context).scaffoldBackgroundColor, + showPageTitle: true, + )); + + @override + void initState() { + super.initState(); + _hideContainersController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); + } + + @override + Widget build(BuildContext context) { + user = Provider.of(context); + settings = Provider.of(context); + updateProvider = Provider.of(context); + kretaClient = Provider.of(context); + + List nameParts = user.displayName?.split(" ") ?? ["?"]; + if (!settings.presentationMode) { + firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0]; + } else { + firstName = "Béla"; + } + + String startPageTitle = SettingsHelper.localizedPageTitles()[settings.startPage] ?? "?"; + String themeModeText = {ThemeMode.light: "light".i18n, ThemeMode.dark: "dark".i18n, ThemeMode.system: "system".i18n}[settings.theme] ?? "?"; + String languageText = SettingsHelper.langMap[settings.language] ?? "?"; + String vibrateTitle = { + VibrationStrength.off: "voff".i18n, + VibrationStrength.light: "vlight".i18n, + VibrationStrength.medium: "vmedium".i18n, + VibrationStrength.strong: "vstrong".i18n, + }[settings.vibrate] ?? + "?"; + + buildAccountTiles(); + + if (settings.developerMode) devmodeCountdown = -1; + + return AnimatedBuilder( + animation: _hideContainersController, + builder: (context, child) => Opacity( + opacity: 1 - _hideContainersController.value, + child: Column( + children: [ + const SizedBox(height: 32.0), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + splashRadius: 32.0, + onPressed: () => _showBottomSheet(user.getUser(user.id ?? "")), + icon: Icon(FeatherIcons.moreVertical, color: AppColors.of(context).text.withOpacity(0.8)), + ), + IconButton( + splashRadius: 26.0, + onPressed: () { + Navigator.of(context).pop(); + }, + icon: Icon(FeatherIcons.x, color: AppColors.of(context).text.withOpacity(0.8)), + ), + ], + ), + + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: ProfileImage( + heroTag: "profile", + radius: 36.0, + onTap: () => _showBottomSheet(user.getUser(user.id ?? "")), + name: firstName, + badge: updateProvider.available, + role: user.role, + profilePictureString: user.picture, + backgroundColor: + !settings.presentationMode ? ColorUtils.stringToColor(user.displayName ?? "?") : Theme.of(context).colorScheme.secondary, + ), + ), + + Padding( + padding: const EdgeInsets.only(top: 4.0, bottom: 12.0), + child: GestureDetector( + onTap: () => _showBottomSheet(user.getUser(user.id ?? "")), + onDoubleTap: () => setState(() => __ss = true), + child: Text( + !settings.presentationMode ? (user.displayName ?? "?") : "Béla", + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.w600, color: AppColors.of(context).text), + ), + ), + ), + + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + child: Column( + children: [ + // Account list + ...accountTiles, + + if (accountTiles.isNotEmpty) + Center( + child: Container( + margin: const EdgeInsets.only(top: 12.0, bottom: 4.0), + height: 3.0, + width: 75.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: AppColors.of(context).text.withOpacity(.25), + ), + ), + ), + + // Account settings + PanelButton( + onPressed: () { + Navigator.of(context).pushNamed("login_back").then((value) { + setSystemChrome(context); + }); + }, + title: Text("add_user".i18n), + leading: const Icon(FeatherIcons.userPlus), + ), + PanelButton( + onPressed: () async { + String? userId = user.id; + if (userId == null) return; + + // Delete User + user.removeUser(userId); + await Provider.of(context, listen: false).store.removeUser(userId); + + // If no other Users left, go back to LoginScreen + if (user.getUsers().isNotEmpty) { + user.setUser(user.getUsers().first.id); + restore().then((_) => user.setUser(user.getUsers().first.id)); + } else { + Navigator.of(context).pushNamedAndRemoveUntil("login", (_) => false); + } + }, + title: Text("log_out".i18n), + leading: Icon(FeatherIcons.logOut, color: AppColors.of(context).red), + ), + ], + ), + ), + ), + + // Updates + if (updateProvider.available) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + child: PanelButton( + onPressed: () => _openUpdates(context), + title: Text("update_available".i18n), + leading: const Icon(FeatherIcons.download), + trailing: Text( + updateProvider.releases.first.tag, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ), + + // const Padding( + // padding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + // child: PremiumBannerButton(), + // ), + if (!context.watch().hasPremium) + const ClipRect( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: PremiumButton(), + ), + ) + else + const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: ActiveSponsorCard(), + ), + + // General Settings + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("general".i18n), + child: Column( + children: [ + PanelButton( + onPressed: () { + SettingsHelper.language(context); + setState(() {}); + }, + title: Text("language".i18n), + leading: const Icon(FeatherIcons.globe), + trailing: Text(languageText), + ), + PanelButton( + onPressed: () { + SettingsHelper.startPage(context); + setState(() {}); + }, + title: Text("startpage".i18n), + leading: const Icon(FeatherIcons.play), + trailing: Text(startPageTitle.capital()), + ), + PanelButton( + onPressed: () { + SettingsHelper.rounding(context); + setState(() {}); + }, + title: Text("rounding".i18n), + leading: const Icon(FeatherIcons.gitCommit), + trailing: Text((settings.rounding / 10).toStringAsFixed(1)), + ), + PanelButton( + onPressed: () { + SettingsHelper.vibrate(context); + setState(() {}); + }, + title: Text("vibrate".i18n), + leading: const Icon(FeatherIcons.radio), + trailing: Text(vibrateTitle), + ), + PanelButton( + padding: const EdgeInsets.only(left: 14.0), + onPressed: () { + SettingsHelper.bellDelay(context); + setState(() {}); + }, + title: Text( + "bell_delay".i18n, + style: TextStyle(color: AppColors.of(context).text.withOpacity(settings.bellDelayEnabled ? 1.0 : .5)), + ), + leading: settings.bellDelayEnabled + ? const Icon(FeatherIcons.bell) + : Icon(FeatherIcons.bellOff, color: AppColors.of(context).text.withOpacity(.25)), + trailingDivider: true, + trailing: Switch( + onChanged: (v) => settings.update(bellDelayEnabled: v), + value: settings.bellDelayEnabled, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ), + ), + ), + + if (kDebugMode) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: const Text("Debug"), + child: Column( + children: [ + PanelButton( + title: const Text("Subject Icon Gallery"), + leading: const Icon(CupertinoIcons.rectangle_3_offgrid_fill), + trailing: const Icon(Icons.arrow_forward), + onPressed: () { + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute(builder: (context) => const SubjectIconGallery()), + ); + }, + ) + ], + ), + ), + ), + + // Secret Settings + if (__ss) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("secret".i18n), + child: Column( + children: [ + // Good student mode + Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Text("goodstudent".i18n, style: const TextStyle(fontWeight: FontWeight.w500)), + onChanged: (v) { + if (v) { + showDialog( + context: context, + builder: (context) => WillPopScope( + onWillPop: () async => false, + child: AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Text("attention".i18n), + content: Text("goodstudent_disclaimer".i18n), + actions: [ + ActionButton( + label: "understand".i18n, + onTap: () { + Navigator.of(context).pop(); + settings.update(goodStudent: v); + Provider.of(context, listen: false).convertBySettings(); + }) + ], + ), + ), + ); + } else { + settings.update(goodStudent: v); + Provider.of(context, listen: false).convertBySettings(); + } + }, + value: settings.goodStudent, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + + // Presentation mode + Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: const Text("Presentation Mode", style: TextStyle(fontWeight: FontWeight.w500)), + onChanged: (v) => settings.update(presentationMode: v), + value: settings.presentationMode, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ), + ), + ), + + // Theme Settings + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("appearance".i18n), + child: Column( + children: [ + PanelButton( + onPressed: () { + SettingsHelper.theme(context); + setState(() {}); + }, + title: Text("theme".i18n), + leading: const Icon(FeatherIcons.sun), + trailing: Text(themeModeText), + ), + PanelButton( + onPressed: () async { + await _hideContainersController.forward(); + SettingsHelper.accentColor(context); + setState(() {}); + _hideContainersController.reset(); + }, + title: Text("color".i18n), + leading: const Icon(FeatherIcons.droplet), + trailing: Container( + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + shape: BoxShape.circle, + ), + ), + ), + PanelButton( + onPressed: () { + SettingsHelper.gradeColors(context); + setState(() {}); + }, + title: Text("grade_colors".i18n), + leading: const Icon(FeatherIcons.star), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + 5, + (i) => Container( + margin: const EdgeInsets.only(left: 2.0), + width: 12.0, + height: 12.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: settings.gradeColors[i], + ), + ), + ), + ), + ), + Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Row( + children: [ + Icon( + FeatherIcons.barChart, + color: settings.graphClassAvg ? Theme.of(context).colorScheme.secondary : AppColors.of(context).text.withOpacity(.25), + ), + const SizedBox(width: 24.0), + Expanded( + child: Text( + "graph_class_avg".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(settings.graphClassAvg ? 1.0 : .5), + ), + ), + ), + ], + ), + onChanged: (v) => settings.update(graphClassAvg: v), + value: settings.graphClassAvg, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + const PremiumIconPackSelector(), + ], + ), + ), + ), + + // Notifications + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("notifications".i18n), + child: Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Row( + children: [ + Icon( + Icons.newspaper_outlined, + color: settings.newsEnabled ? Theme.of(context).colorScheme.secondary : AppColors.of(context).text.withOpacity(.25), + ), + const SizedBox(width: 24.0), + Expanded( + child: Text( + "news".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(settings.newsEnabled ? 1.0 : .5), + ), + ), + ), + ], + ), + onChanged: (v) => settings.update(newsEnabled: v), + value: settings.newsEnabled, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + + // Extras + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("extras".i18n), + child: Column( + children: [ + Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: Row( + children: [ + Icon( + FeatherIcons.gift, + color: settings.gradeOpeningFun ? Theme.of(context).colorScheme.secondary : AppColors.of(context).text.withOpacity(.25), + ), + const SizedBox(width: 24.0), + Expanded( + child: Text( + "surprise_grades".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(settings.gradeOpeningFun ? 1.0 : .5), + ), + ), + ), + ], + ), + onChanged: (v) => settings.update(gradeOpeningFun: v), + value: settings.gradeOpeningFun, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + MenuRenamedSubjects( + settings: settings, + ), + ], + ), + ), + ), + + // About + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: Text("about".i18n), + child: Column(children: [ + PanelButton( + leading: const Icon(FeatherIcons.atSign), + title: const Text("Discord"), + onPressed: () => launchUrl(Uri.parse("https://filcnaplo.hu/discord"), mode: LaunchMode.externalApplication), + ), + PanelButton( + leading: const Icon(FeatherIcons.globe), + title: const Text("www.filcnaplo.hu"), + onPressed: () => launchUrl(Uri.parse("https://filcnaplo.hu"), mode: LaunchMode.externalApplication), + ), + PanelButton( + leading: const Icon(FeatherIcons.github), + title: const Text("Github"), + onPressed: () => launchUrl(Uri.parse("https://github.com/filc"), mode: LaunchMode.externalApplication), + ), + PanelButton( + leading: const Icon(FeatherIcons.mail), + title: Text("news".i18n), + onPressed: () => _openNews(context), + ), + PanelButton( + leading: const Icon(FeatherIcons.lock), + title: Text("privacy".i18n), + onPressed: () => _openPrivacy(context), + ), + PanelButton( + leading: const Icon(FeatherIcons.award), + title: Text("licenses".i18n), + onPressed: () => showLicensePage(context: context), + ), + Tooltip( + message: "data_collected".i18n, + padding: const EdgeInsets.all(4.0), + textStyle: TextStyle(fontWeight: FontWeight.w500, color: AppColors.of(context).text), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.background), + child: Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + secondary: Icon( + FeatherIcons.barChart2, + color: settings.xFilcId != "none" ? Theme.of(context).colorScheme.secondary : AppColors.of(context).text.withOpacity(.25), + ), + title: Text( + "Analytics".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(settings.xFilcId != "none" ? 1.0 : .5), + ), + ), + subtitle: Text( + "Anonymous Usage Analytics".i18n, + style: TextStyle( + color: AppColors.of(context).text.withOpacity(settings.xFilcId != "none" ? .5 : .2), + ), + ), + onChanged: (v) { + String newId; + if (v == false) { + newId = "none"; + } else if (settings.xFilcId == "none") { + newId = SettingsProvider.defaultSettings().xFilcId; + } else { + newId = settings.xFilcId; + } + settings.update(xFilcId: newId); + }, + value: settings.xFilcId != "none", + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ]), + ), + ), + if (settings.developerMode) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Panel( + title: const Text("Developer Settings"), + child: Column( + children: [ + Material( + type: MaterialType.transparency, + child: SwitchListTile( + contentPadding: const EdgeInsets.only(left: 12.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)), + title: const Text("Developer Mode", style: TextStyle(fontWeight: FontWeight.w500)), + onChanged: (v) => settings.update(developerMode: false), + value: settings.developerMode, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ), + PanelButton( + leading: const Icon(FeatherIcons.copy), + title: const Text("Copy JWT"), + onPressed: () => Clipboard.setData(ClipboardData(text: Provider.of(context, listen: false).accessToken)), + ), + if (Provider.of(context, listen: false).hasPremium) + PanelButton( + leading: const Icon(FeatherIcons.key), + title: const Text("Remove Premium"), + onPressed: () { + Provider.of(context, listen: false).activate(removePremium: true); + settings.update(accentColor: AccentColor.filc, store: true); + Provider.of(context, listen: false).changeTheme(settings.theme); + }, + ), + ], + ), + ), + ), + SafeArea( + top: false, + child: Center( + child: GestureDetector( + child: const Panel(title: Text("v" + String.fromEnvironment("APPVER", defaultValue: "?"))), + onTap: () { + if (devmodeCountdown > 0) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + duration: const Duration(milliseconds: 200), + content: Text("You are $devmodeCountdown taps away from Developer Mode."), + )); + + setState(() => devmodeCountdown--); + } else if (devmodeCountdown == 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text("Developer Mode successfully activated."), + )); + + settings.update(developerMode: true); + + setState(() => devmodeCountdown--); + } + }, + ), + ), + ), + ], + ), + ), + ); + } + + void _openNews(BuildContext context) => + Navigator.of(context, rootNavigator: true).push(CupertinoPageRoute(builder: (context) => const NewsScreen())); + void _openUpdates(BuildContext context) => UpdateView.show(updateProvider.releases.first, context: context); + void _openPrivacy(BuildContext context) => PrivacyView.show(context); +} diff --git a/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.i18n.dart b/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.i18n.dart new file mode 100755 index 0000000..a6abc48 --- /dev/null +++ b/filcnaplo_mobile_ui/lib/screens/settings/settings_screen.i18n.dart @@ -0,0 +1,194 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension SettingsLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "personal_details": "Personal Details", + "open_dkt": "Open DKT", + "edit_nickname": "Edit Nickname", + "edit_profile_picture": "Edit Profile Picture", + "remove_profile_picture": "Remove Profile Picture", + "select_profile_picture": "to select a picture", + "click_here": "Click here", + "light": "Light", + "dark": "Dark", + "system": "System", + "add_user": "Add User", + "log_out": "Log Out", + "update_available": "Update Available", + "general": "General", + "language": "Language", + "startpage": "Startpage", + "rounding": "Rounding", + "appearance": "Appearance", + "theme": "Theme", + "color": "Color", + "grade_colors": "Grade Colors", + "notifications": "Notifications", + "news": "News", + "extras": "Extras", + "about": "About", + "supporters": "Supporters", + "privacy": "Privacy Policy", + "licenses": "Licenses", + "vibrate": "Vibration", + "voff": "Off", + "vlight": "Light", + "vmedium": "Medium", + "vstrong": "Strong", + "cancel": "Cancel", + "done": "Done", + "reset": "Reset", + "open": "Open", + "data_collected": "Data collected: Platform (eg. Android), App version (eg. 3.0.0), Unique Install Identifier", + "Analytics": "Analytics", + "Anonymous Usage Analytics": "Anonymous Usage Analytics", + "graph_class_avg": "Class average on graph", + "goodstudent": "Good student mode", + "attention": "Attention!", + "goodstudent_disclaimer": + "Filc Napló® Informatikai Zrt. can not be held liable for the usage of this feature.\n\n(if your mother beats you up because you showed her fake grades, you can only blame yourself for it)", + "understand": "I understand", + "secret": "Secret Settings", + "bell_delay": "Bell Delay", + "delay": "Delay", + "hurry": "Hurry", + "sync": "Synchronize", + "sync_help": "Press the Synchronize button when the bell rings.", + "surprise_grades": "Surprise Grades", + "icon_pack": "Icon Pack", + "change_username": "Set a nickname", + "Accent Color": "Accent Color", + "Background Color": "Background Color", + "Highlight Color": "Highlight Color", + "Adaptive Theme": "Adaptive Theme", + }, + "hu_hu": { + "personal_details": "Személyes információk", + "open_dkt": "DKT megnyitása", + "edit_nickname": "Becenév szerkesztése", + "edit_profile_picture": "Profil-kép szerkesztése", + "remove_profile_picture": "Profil-kép törlése", + "select_profile_picture": "a kép kiválasztásához", + "click_here": "Kattints ide", + "light": "Világos", + "dark": "Sötét", + "system": "Rendszer", + "add_user": "Felhasználó hozzáadása", + "log_out": "Kijelentkezés", + "update_available": "Frissítés elérhető", + "general": "Általános", + "language": "Nyelv", + "startpage": "Kezdőlap", + "rounding": "Kerekítés", + "appearance": "Kinézet", + "theme": "Téma", + "color": "Színek", + "grade_colors": "Jegyek színei", + "notifications": "Értesítések", + "news": "Hírek", + "extras": "Extrák", + "about": "Névjegy", + "supporters": "Támogatók", + "privacy": "Adatvédelmi irányelvek", + "licenses": "Licenszek", + "vibrate": "Rezgés", + "voff": "Kikapcsolás", + "vlight": "Alacsony", + "vmedium": "Közepes", + "vstrong": "Erős", + "cancel": "Mégsem", + "done": "Kész", + "reset": "Visszaállítás", + "open": "Megnyitás", + "data_collected": "Gyűjtött adat: Platform (pl. Android), App verzió (pl. 3.0.0), Egyedi telepítési azonosító", + "Analytics": "Analitika", + "Anonymous Usage Analytics": "Névtelen használati analitika", + "graph_class_avg": "Osztályátlag a grafikonon", + "goodstudent": "Jó tanuló mód", + "attention": "Figyelem!", + "goodstudent_disclaimer": + "A Filc Napló® Informatikai Zrt. minden felelősséget elhárít a funkció használatával kapcsolatban.\n\n(Értsd: ha az anyád megver, mert megtévesztő ábrákat mutattál neki, azért csakis magadadat hibáztathatod.)", + "understand": "Értem", + "secret": "Titkos Beállítások", + "bell_delay": "Csengő eltolódása", + "delay": "Késleltetés", + "hurry": "Siettetés", + "sync": "Szinkronizálás", + "sync_help": "Csengetéskor nyomd meg a Szinkronizálás gombot.", + "surprise_grades": "Meglepetés jegyek", + "icon_pack": "Ikon séma", + "change_username": "Becenév beállítása", + "Accent Color": "Egyedi szín", + "Background Color": "Háttér színe", + "Highlight Color": "Panelek színe", + "Adaptive Theme": "Adaptív téma", + }, + "de_de": { + "personal_details": "Persönliche Angaben", + "open_dkt": "Öffnen DKT", + "edit_nickname": "Spitznamen bearbeiten", + "edit_profile_picture": "Profilbild bearbeiten", + "remove_profile_picture": "Profilbild entfernen", + "select_profile_picture": "um ein Bild auszuwählen", + "click_here": "Klick hier", + "light": "Licht", + "dark": "Dunkel", + "system": "System", + "add_user": "Benutzer hinzufügen", + "log_out": "Abmelden", + "update_available": "Update verfügbar", + "general": "Allgemein", + "language": "Sprache", + "startpage": "Startseite", + "rounding": "Rundung", + "appearance": "Erscheinungsbild", + "theme": "Thema", + "color": "Farbe", + "grade_colors": "Grad Farben", + "notifications": "Benachrichtigungen", + "news": "Nachrichten", + "extras": "Extras", + "about": "Informationen", + "supporters": "Unterstützer", + "privacy": "Datenschutzbestimmungen", + "licenses": "Lizenzen", + "vibrate": "Vibration", + "voff": "Aus", + "vlight": "Leicht", + "vmedium": "Mittel", + "vstrong": "Stark", + "cancel": "Abbrechen", + "done": "Fertig", + "reset": "Zurücksetzen", + "open": "Öffnen", + "data_collected": "Erhobene Daten: Plattform (z.B. Android), App version (z.B. 3.0.0), Eindeutige Installationskennung", + "Analytics": "Analytik", + "Anonymous Usage Analytics": "Anonyme Nutzungsanalyse", + "graph_class_avg": "Klassendurchschnitt in der Grafik", + "goodstudent": "Guter Student Modus", + "attention": "Achtung!", + "goodstudent_disclaimer": "Same in English.", + "understand": "Ich verstehe", + "secret": "Geheime Einstellungen", + "bell_delay": "Klingelverzögerung", + "delay": "Verzögern", + "hurry": "Eile", + "sync": "Synchronisieren", + "sync_help": "Drücken Sie die Sync-Taste, wenn die Glocke läutet.", + "surprise_grades": "Überraschungsnoten", + "icon_pack": "Icon-Pack", + "change_username": "Einen Spitznamen festlegen", + "Accent Color": "Accent Color", + "Background Color": "Background Color", + "Highlight Color": "Highlight Color", + "Adaptive Theme": "Adaptive Theme", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_mobile_ui/pubspec.yaml b/filcnaplo_mobile_ui/pubspec.yaml new file mode 100755 index 0000000..93c8318 --- /dev/null +++ b/filcnaplo_mobile_ui/pubspec.yaml @@ -0,0 +1,47 @@ +name: filcnaplo_mobile_ui +publish_to: "none" + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + + # Filcnaplo main dep + filcnaplo: + path: ../filcnaplo/ + filcnaplo_kreta_api: + path: ../filcnaplo_kreta_api/ + filcnaplo_premium: + path: ../filcnaplo_premium/ + + flutter_feather_icons: ^2.0.0+1 + provider: ^5.0.0 + fl_chart: ^0.45.1 + url_launcher: ^6.0.9 + flutter_material_color_picker: ^1.1.0+2 + photo_view: ^0.13.0 + flutter_linkify: ^5.0.2 + flutter_custom_tabs: ^1.0.3 + flutter_markdown: ^0.6.5 + animations: ^2.0.1 + animated_list_plus: ^0.5.0 + sliding_sheet: ^0.5.2 + confetti: ^0.6.0 + live_activities: ^1.0.0 + animated_flip_counter: ^0.2.5 + lottie: ^1.4.3 + rive: ^0.9.1 + animated_background: ^2.0.0 + home_widget: ^0.1.6 + dropdown_button2: ^1.8.9 + flutter_svg: ^1.1.6 + background_fetch: ^1.1.5 + +dev_dependencies: + flutter_lints: ^1.0.0 + +flutter: + uses-material-design: true