diff --git a/lib/app/app_config.dart b/lib/app/app_config.dart index f969390a..8962ea92 100644 --- a/lib/app/app_config.dart +++ b/lib/app/app_config.dart @@ -118,6 +118,7 @@ class AppConfig { overline: TextStyle( fontWeight: FontWeight.bold, fontSize: 12, + color: brightness.disabledOnColor, ), ); } diff --git a/lib/app/sort_filter/filtering.dart b/lib/app/sort_filter/filtering.dart index 7cff4bbf..88a202a0 100644 --- a/lib/app/sort_filter/filtering.dart +++ b/lib/app/sort_filter/filtering.dart @@ -1,17 +1,20 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:datetime_picker_formfield/datetime_picker_formfield.dart'; import 'package:flutter/material.dart'; +import 'package:hive_cache/hive_cache.dart'; import 'package:intl/intl.dart'; import 'package:time_machine/time_machine.dart'; import 'package:time_machine/time_machine_text_patterns.dart'; import '../datetime_utils.dart'; import '../utils.dart'; +import '../widgets/cache_utils.dart'; import 'sort_filter.dart'; typedef Test = bool Function(T item, D data); typedef Selector = R Function(T item); +@immutable abstract class Filter { const Filter(this.titleGetter) : assert(titleGetter != null); @@ -19,7 +22,7 @@ abstract class Filter { final L10nStringGetter titleGetter; - S tryParseWebQuerySorter(Map query, String key); + S tryParseWebQuery(Map query, String key); bool filter(T item, S selection); Widget buildWidget( @@ -36,7 +39,6 @@ abstract class Filter { } } -@immutable class DateRangeFilter extends Filter { const DateRangeFilter( L10nStringGetter titleGetter, { @@ -54,7 +56,7 @@ class DateRangeFilter extends Filter { final String webQueryKey; @override - DateRangeFilterSelection tryParseWebQuerySorter( + DateRangeFilterSelection tryParseWebQuery( Map query, String key) { LocalDate tryParse(String value) { if (value == null) { @@ -84,12 +86,14 @@ class DateRangeFilter extends Filter { DateRangeFilterSelection selection, DataChangeCallback updater, ) { + final s = context.s; + return Row( children: [ Expanded( child: _buildDateField( date: selection.start, - hintText: 'from', + hintText: s.app_dateRangeFilter_start, onChanged: (newStart) => updater(selection.withStart(newStart)), lastDate: selection.end, ), @@ -100,7 +104,7 @@ class DateRangeFilter extends Filter { Expanded( child: _buildDateField( date: selection.end, - hintText: 'until', + hintText: s.app_dateRangeFilter_end, onChanged: (newEnd) => updater(selection.withEnd(newEnd)), firstDate: selection.start, ), @@ -149,7 +153,80 @@ class DateRangeFilterSelection { DateRangeFilterSelection(start: start, end: end); } -@immutable +typedef CategoryLabelBuilder = Widget Function( + BuildContext context, C category); +typedef WebQueryCategoryParser = C Function(String value); + +class CategoryFilter> extends Filter>> { + const CategoryFilter( + L10nStringGetter titleGetter, { + @required this.selector, + @required this.categoriesCollection, + @required this.categoryLabelBuilder, + this.defaultSelection = const {}, + this.webQueryKey, + @required this.webQueryParser, + }) : assert(selector != null), + assert(categoriesCollection != null), + assert(categoryLabelBuilder != null), + assert(defaultSelection != null), + assert(webQueryParser != null), + super(titleGetter); + + final Selector> selector; + final Collection categoriesCollection; + final CategoryLabelBuilder> categoryLabelBuilder; + + @override + final Set> defaultSelection; + + final String webQueryKey; + final WebQueryCategoryParser> webQueryParser; + + @override + Set> tryParseWebQuery(Map query, String key) { + final queryValue = query[webQueryKey ?? key]; + return queryValue.split(',').map(webQueryParser).toSet(); + } + + @override + bool filter(T item, Set> selection) { + final category = selector(item); + return category == null || + selection.isEmpty || + selection.contains(category); + } + + @override + Widget buildWidget( + BuildContext context, + Set> selection, + DataChangeCallback>> updater, + ) { + return CollectionBuilder( + collection: categoriesCollection, + builder: handleLoadingError((context, categoryIds, _) { + return ChipGroup( + children: [ + for (final category in categoryIds) + FilterChip( + selected: selection.contains(category), + onSelected: (isSelected) { + if (isSelected) { + updater({...selection, category}); + } else { + updater(selection.toSet().difference({category})); + } + }, + label: categoryLabelBuilder(context, category), + ) + ], + ); + }), + ); + } +} + class FlagsFilter extends Filter> { const FlagsFilter(L10nStringGetter titleGetter, {@required this.filters}) : assert(filters != null), @@ -166,8 +243,7 @@ class FlagsFilter extends Filter> { final Map> filters; @override - Map tryParseWebQuerySorter( - Map query, String key) { + Map tryParseWebQuery(Map query, String key) { return { for (final entry in filters.entries) entry.key: entry.value.tryParseWebQuerySorter(query, entry.key), diff --git a/lib/app/sort_filter/sort_filter.dart b/lib/app/sort_filter/sort_filter.dart index ce2da171..0a9e17a8 100644 --- a/lib/app/sort_filter/sort_filter.dart +++ b/lib/app/sort_filter/sort_filter.dart @@ -55,7 +55,7 @@ class SortFilter { sortOrder: SortOrderUtils.tryParseWebQuery(query) ?? defaultSortOrder, filterSelections: { for (final entry in filters.entries) - entry.key: entry.value.tryParseWebQuerySorter(query, entry.key), + entry.key: entry.value.tryParseWebQuery(query, entry.key), }, ); } @@ -124,18 +124,10 @@ class SortFilterSelection { ) { assert(config.filters[flagsKey] is FlagsFilter); - return SortFilterSelection( - config: config, - sortSelectionKey: sortSelectionKey, - sortOrder: sortOrder, - filterSelections: { - ...filterSelections, - flagsKey: { - ...filterSelections[flagsKey], - flag: selection, - } - }, - ); + return withFilterSelection(flagsKey, { + ...filterSelections[flagsKey], + flag: selection, + }); } List apply(List allItems) { @@ -157,21 +149,25 @@ class SortFilterSelection { var currentSelection = this; context.showFancyModalBottomSheet( + // Gives us more vertical space. + isScrollControlled: true, useRootNavigator: true, - builder: (_) => Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: StatefulBuilder( - builder: (_, setState) { - return SortFilterSelectionWidget( - selection: currentSelection, - onSelectionChange: (selection) { - setState(() => currentSelection = selection); - callback(selection); - }, - ); - }, - ), - ), + builder: (_) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: StatefulBuilder( + builder: (_, setState) { + return SortFilterSelectionWidget( + selection: currentSelection, + onSelectionChange: (selection) { + setState(() => currentSelection = selection); + callback(selection); + }, + ); + }, + ), + ); + }, ); } } @@ -249,11 +245,11 @@ class _Section extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: EdgeInsets.symmetric(vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(title, style: context.textTheme.overline), + Text(title.toUpperCase(), style: context.textTheme.overline), SizedBox(height: 4), child, ], @@ -328,6 +324,11 @@ mixin SortFilterStateMixin, T> on State { setState(() => sortFilterSelection = selection); } + void setFilter(String key, dynamic selection) { + updateSortFilterSelection( + sortFilterSelection.withFilterSelection(key, selection)); + } + // ignore: avoid_positional_boolean_parameters void setFlagFilter(String key, bool value) { updateSortFilterSelection( diff --git a/lib/app/widgets/card.dart b/lib/app/widgets/card.dart index dcc9aa89..8347f19b 100644 --- a/lib/app/widgets/card.dart +++ b/lib/app/widgets/card.dart @@ -1,8 +1,6 @@ import 'package:black_hole_flutter/black_hole_flutter.dart'; import 'package:flutter/material.dart'; -import 'text.dart'; - class FancyCard extends StatelessWidget { const FancyCard({ Key key, @@ -55,11 +53,7 @@ class FancyCard extends StatelessWidget { if (title != null) Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16), - child: FancyText( - title.toUpperCase(), - style: context.textTheme.overline, - emphasis: TextEmphasis.disabled, - ), + child: Text(title.toUpperCase(), style: context.textTheme.overline), ), Padding( padding: omitHorizontalPadding diff --git a/lib/app/widgets/text.dart b/lib/app/widgets/text.dart index d7e797d8..15d427d9 100644 --- a/lib/app/widgets/text.dart +++ b/lib/app/widgets/text.dart @@ -18,7 +18,7 @@ class FancyText extends StatefulWidget { this.maxLines, this.textType = TextType.plain, this.showRichText = false, - this.estimatedLines, + this.estimatedWidth, this.style, this.emphasis, this.textAlign, @@ -27,8 +27,8 @@ class FancyText extends StatefulWidget { assert(showRichText != null), assert(!showRichText || maxLines == null, "maxLines isn't supported in combination with showRichText."), - assert((estimatedLines ?? maxLines) == null || - (estimatedLines ?? maxLines) > 0), + assert(maxLines == null || maxLines > 0), + assert(estimatedWidth == null || estimatedWidth > 0), assert(!showRichText || textAlign == null, "textAlign isn't supported in combination with showRichText."), super(key: key); @@ -37,7 +37,7 @@ class FancyText extends StatefulWidget { String data, { Key key, TextType textType = TextType.html, - int estimatedLines, + double estimatedWidth, TextStyle style, TextEmphasis emphasis, }) : this( @@ -45,7 +45,7 @@ class FancyText extends StatefulWidget { key: key, textType: textType, showRichText: true, - estimatedLines: estimatedLines, + estimatedWidth: estimatedWidth, style: style, emphasis: emphasis, ); @@ -55,7 +55,7 @@ class FancyText extends StatefulWidget { Key key, int maxLines = 1, TextType textType = TextType.html, - int estimatedLines, + double estimatedWidth, TextStyle style, }) : this( data, @@ -63,7 +63,7 @@ class FancyText extends StatefulWidget { maxLines: maxLines, textType: textType, showRichText: false, - estimatedLines: estimatedLines, + estimatedWidth: estimatedWidth, style: style, emphasis: TextEmphasis.medium, ); @@ -72,7 +72,7 @@ class FancyText extends StatefulWidget { final int maxLines; final TextType textType; final bool showRichText; - final int estimatedLines; + final double estimatedWidth; final TextStyle style; final TextEmphasis emphasis; final TextAlign textAlign; @@ -82,15 +82,15 @@ class FancyText extends StatefulWidget { } class _FancyTextState extends State { - double previewLines; + double lastLineWidthFactor; @override void initState() { super.initState(); - previewLines = (widget.estimatedLines ?? widget.maxLines ?? 1) - - 1 + - lerpDouble(0.2, 0.9, Random().nextDouble()); + if (widget.estimatedWidth == null) { + lastLineWidthFactor = lerpDouble(0.2, 0.9, Random().nextDouble()); + } } @override @@ -140,18 +140,20 @@ class _FancyTextState extends State { final resolvedStyle = DefaultTextStyle.of(context).style.merge(style); final color = context.theme.isDark ? theme.disabledColor : Colors.black38; - Widget buildBar(double widthFactor) { + Widget buildBar({double width, double widthFactor}) { + assert((width == null) != (widthFactor == null)); return Material( shape: StadiumBorder(), color: color, child: FractionallySizedBox( widthFactor: widthFactor, - child: SizedBox(height: resolvedStyle.fontSize), + child: SizedBox(width: width, height: resolvedStyle.fontSize), ), ); } - final fullLines = previewLines.floor(); + assert(widget.estimatedWidth == null || lastLineWidthFactor == null); + final fullLines = widget.maxLines != null ? widget.maxLines - 1 : 0; final lineSpacing = ((resolvedStyle.height ?? 1.5) - 1) * resolvedStyle.fontSize; return Column( @@ -159,10 +161,13 @@ class _FancyTextState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < fullLines; i++) ...[ - buildBar(1), + buildBar(widthFactor: 1), SizedBox(height: lineSpacing) ], - buildBar(previewLines - fullLines), + buildBar( + width: widget.estimatedWidth, + widthFactor: lastLineWidthFactor, + ), ], ); } diff --git a/lib/assignment/widgets/assignment_detail_screen.dart b/lib/assignment/widgets/assignment_detail_screen.dart index 9a765a56..94d653dd 100644 --- a/lib/assignment/widgets/assignment_detail_screen.dart +++ b/lib/assignment/widgets/assignment_detail_screen.dart @@ -50,7 +50,7 @@ class _AssignmentDetailScreenState extends State initialTabIndex: initialTabIndex, appBarBuilder: (_) => FancyAppBar( title: Text(assignment.name), - subtitle: _buildSubtitle(context, assignment.courseId), + subtitle: CourseName.orNull(assignment.courseId), actions: [ if (user.hasPermission(Permission.assignmentEdit)) _buildArchiveAction(context, assignment), @@ -82,23 +82,6 @@ class _AssignmentDetailScreenState extends State ); } - Widget _buildSubtitle(BuildContext context, Id courseId) { - if (courseId == null) { - return null; - } - - return EntityBuilder( - id: courseId, - builder: handleError((context, course, _) { - return Row(children: [ - CourseColorDot(course), - SizedBox(width: 8), - Text(course?.name ?? context.s.general_loading), - ]); - }), - ); - } - Widget _buildArchiveAction(BuildContext context, Assignment assignment) { final s = context.s; diff --git a/lib/assignment/widgets/assignments_screen.dart b/lib/assignment/widgets/assignments_screen.dart index 65355f83..a52db22b 100644 --- a/lib/assignment/widgets/assignments_screen.dart +++ b/lib/assignment/widgets/assignments_screen.dart @@ -40,6 +40,13 @@ class AssignmentsScreen extends SortFilterWidget { selector: (assignment) => assignment.dueAt?.inLocalZone()?.calendarDate, defaultSelection: DateRangeFilterSelection(start: LocalDate.today()), ), + 'courseId': CategoryFilter( + (s) => s.assignment_assignment_property_course, + selector: (assignment) => assignment.courseId, + categoriesCollection: services.storage.root.courses, + categoryLabelBuilder: (_, courseId) => CourseName(courseId), + webQueryParser: (value) => Id(value), + ), 'more': FlagsFilter( (s) => s.general_entity_property_more, filters: { @@ -101,6 +108,16 @@ class _AssignmentsScreenState extends State ), child: AssignmentCard( assignment: assignments[index], + onCourseClicked: (courseId) => + setFilter('courseId', {courseId}), + onOverdueClicked: () { + setFilter( + 'dueAt', + DateRangeFilterSelection( + end: LocalDate.today() - Period(days: 1), + ), + ); + }, setFlagFilterCallback: setFlagFilter, ), ), @@ -116,24 +133,28 @@ class _AssignmentsScreenState extends State } } +typedef CourseClickedCallback = void Function(Id courseId); + class AssignmentCard extends StatelessWidget { const AssignmentCard({ @required this.assignment, + @required this.onCourseClicked, + @required this.onOverdueClicked, @required this.setFlagFilterCallback, }) : assert(assignment != null), + assert(onCourseClicked != null), + assert(onOverdueClicked != null), assert(setFlagFilterCallback != null); final Assignment assignment; + final CourseClickedCallback onCourseClicked; + final VoidCallback onOverdueClicked; final SetFlagFilterCallback setFlagFilterCallback; - void _showAssignmentDetailsScreen(BuildContext context) { - context.navigator.pushNamed('/homework/${assignment.id}'); - } - @override Widget build(BuildContext context) { return FancyCard( - onTap: () => _showAssignmentDetailsScreen(context), + onTap: () => context.navigator.pushNamed('/homework/${assignment.id}'), omitBottomPadding: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -174,16 +195,10 @@ class AssignmentCard extends StatelessWidget { return [ if (assignment.courseId != null) - EntityBuilder( - id: assignment.courseId, - builder: handleError((context, course, fetch) { - return CourseChip( - course, - onPressed: () { - // TODO(JonasWanke): filter list by course, https://github.com/schul-cloud/schulcloud-flutter/issues/145 - }, - ); - }), + CourseChip( + assignment.courseId, + key: ValueKey(assignment.courseId), + onPressed: () => onCourseClicked(assignment.courseId), ), if (assignment.isOverdue) ActionChip( @@ -192,7 +207,7 @@ class AssignmentCard extends StatelessWidget { color: context.theme.errorColor, ), label: Text(s.assignment_assignment_overdue), - onPressed: () {}, + onPressed: onOverdueClicked, ), if (assignment.isArchived) FlagFilterPreviewChip( diff --git a/lib/course/course.dart b/lib/course/course.dart index df838926..78e3aec0 100644 --- a/lib/course/course.dart +++ b/lib/course/course.dart @@ -3,4 +3,5 @@ export 'routes.dart'; export 'widgets/course_chip.dart'; export 'widgets/course_color_dot.dart'; export 'widgets/course_detail_screen.dart'; +export 'widgets/course_name.dart'; export 'widgets/courses_screen.dart'; diff --git a/lib/course/widgets/course_chip.dart b/lib/course/widgets/course_chip.dart index db8d9593..0a110a9c 100644 --- a/lib/course/widgets/course_chip.dart +++ b/lib/course/widgets/course_chip.dart @@ -6,25 +6,34 @@ import '../data.dart'; import 'course_color_dot.dart'; class CourseChip extends StatelessWidget { - const CourseChip(this.course, {Key key, this.onPressed}) : super(key: key); + const CourseChip(this.courseId, {Key key, this.onPressed}) + : assert(courseId != null), + super(key: key); - final Course course; + final Id courseId; final VoidCallback onPressed; @override Widget build(BuildContext context) { - if (onPressed == null && course == null) { - return Chip( - avatar: CourseColorDot(course), - label: FancyText(course?.name), - ); - } + return EntityBuilder( + id: courseId, + builder: (_, update, __) { + final course = update.data; - return ActionChip( - avatar: CourseColorDot(course), - label: FancyText(course?.name), - onPressed: onPressed ?? - () => context.navigator.pushNamed('/courses/${course.id}'), + if (onPressed == null && course == null) { + return Chip( + avatar: CourseColorDot(course), + label: FancyText(course?.name, estimatedWidth: 96), + ); + } + + return ActionChip( + avatar: CourseColorDot(course), + label: FancyText(course?.name, estimatedWidth: 96), + onPressed: onPressed ?? + () => context.navigator.pushNamed('/courses/${course.id}'), + ); + }, ); } } diff --git a/lib/course/widgets/course_name.dart b/lib/course/widgets/course_name.dart new file mode 100644 index 00000000..eed495c9 --- /dev/null +++ b/lib/course/widgets/course_name.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:schulcloud/app/app.dart'; + +import '../data.dart'; +import 'course_color_dot.dart'; + +class CourseName extends StatelessWidget { + const CourseName(this.courseId, {Key key}) + : assert(courseId != null), + super(key: key); + factory CourseName.orNull(Id courseId, {Key key}) => + courseId != null ? CourseName(courseId, key: key) : null; + + final Id courseId; + + @override + Widget build(BuildContext context) { + return EntityBuilder( + id: courseId, + builder: (_, update, __) { + final course = update.data; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CourseColorDot(course), + SizedBox(width: 8), + FancyText(course?.name, estimatedWidth: 96), + ], + ); + }, + ); + } +} diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 436bebfd..bbaa92b3 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,5 +1,7 @@ { "@@locale": "de", + "app_dateRangeFilter_start": "von", + "app_dateRangeFilter_end": "bis", "app_demo": "Demo", "app_demo_explanation": "Dies ist ein Demo-Account. Sämtliche Aktionen, die Daten anlegen oder ändern, sind deaktiviert und nicht sichtbar.", "app_emptyState_retry": "Nochmal versuchen", @@ -32,6 +34,7 @@ "assignment_assignment_isPrivate": "Privat", "assignment_assignment_overdue": "Überfällig", "assignment_assignment_property_availableAt": "Verfügbarkeitsdatum", + "assignment_assignment_property_course": "Kurs", "assignment_assignment_property_dueAt": "Abgabedatum", "assignment_assignment_property_hasPublicSubmissions": "Öffentliche Abgaben", "assignment_assignment_property_isPrivate": "Private Aufgabe", @@ -158,4 +161,4 @@ "signIn_signInScreen_faq_getAccountA": "Bitte wende dich an den Administrator deiner Schule, um einen Account für die {brand} zu erhalten.", "signIn_signInScreen_moreInformation": "Scrolle herunter für mehr Informationen", "signIn_signOutScreen_message": "Abmelden…" -} \ No newline at end of file +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 77243c8b..10f734ae 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,5 +1,7 @@ { "@@locale": "en", + "app_dateRangeFilter_start": "from", + "app_dateRangeFilter_end": "until", "app_demo": "Demo", "app_demo_explanation": "This is a demo account. All actions that create or change data are deactivated and not visible.", "app_emptyState_retry": "Try again", @@ -32,6 +34,7 @@ "assignment_assignment_isPrivate": "Private", "assignment_assignment_overdue": "Overdue", "assignment_assignment_property_availableAt": "Available date", + "assignment_assignment_property_course": "Course", "assignment_assignment_property_dueAt": "Due date", "assignment_assignment_property_hasPublicSubmissions": "Public submissions", "assignment_assignment_property_isPrivate": "Private assignment", @@ -159,4 +162,4 @@ "signIn_signInScreen_faq_getAccountA": "Please contact your school's administrator to get an account for the {brand}.", "signIn_signInScreen_moreInformation": "scroll down for more information", "signIn_signOutScreen_message": "Signing out…" -} \ No newline at end of file +}