Skip to content

Commit

Permalink
Add substitution feature (#1419)
Browse files Browse the repository at this point in the history
## Description.

This PR adds the feature of a substitution schedule. Users can:

* Mark a lesson as canceled
* Change the room

Users can inform their group members that a substitution has been
changed. The name of the user who made the change will be displayed in
the class sheet. For the timetable, the substitution will be displayed.
For the dashboard: Canceled lessons are not shown, room changes are
shown (without showing the old room) - we don't have enough space there.

Only users who have permission to change lessons are allowed to make
changes in the substitution plan (in this case "Aktive Kursmitglied" and
"Administrator").

## How can I access this feature?

For now, anyone who is on the Alpha or Beta track. However, once this
feature is on the stable track, only Sharezone Plus users will have
access to this feature.

## Where are substitutions stored?

We store substitutions in the lesson document. This makes it easy to
access the substitutions. The complete layout looks like this:

```
substitutions: {
    [substitutionId]: {
        type: "cancelled" | "placeChanged" | "unknown",
        date: "YYYY-MM-DD",
        created: {
            on: FieldValue.serverTimestamp(),
            by: "userId"
            notifyGroupMembers: true | false
        },
        // Only for type "placeChanged"
        updated: {
            on: FieldValue.serverTimestamp(),
            by: "userId",
            notifyGroupMembers: true | false,
            (newPlace: "string")
        },
        // We don't delete substitutions, we mark them as deleted. This is to
        // notify the group members that the substitution was removed but still
        // support offline mode. 
        deleted: {
            on: FieldValue.serverTimestamp(),
            by: "userId",
            notifyGroupMembers: true | false
        },
    }
}
```

## Demo

_The demo is missing the "Beta" tag on the right side._

Screenshot:


![image](https://github.com/SharezoneApp/sharezone-app/assets/24459435/a97c2785-baea-41e4-ad99-f63fc8ab8caf)

Video:


https://github.com/SharezoneApp/sharezone-app/assets/24459435/9f6ba728-e174-4385-89b7-1f7fe65012c0

The sheet is also scrollable but still draggable:


https://github.com/SharezoneApp/sharezone-app/assets/24459435/ce75704e-f207-47a5-bb57-bc977fea0953

New timetable FAB sheet (screenshot):


![image](https://github.com/SharezoneApp/sharezone-app/assets/24459435/488b21a6-2036-47f1-a8f6-c2c3fc02b092)

Option "Vertretungsplan" in timetable FAB sheet:


https://github.com/SharezoneApp/sharezone-app/assets/24459435/155894f9-3011-4bc5-b60e-4fd0884bae44

This option is only there to make the feature more visible. The user
can't add substitutions with this button.

**Notification:**


![Screenshot_20240505-171102](https://github.com/SharezoneApp/sharezone-app/assets/24459435/116d0960-0ea0-4245-85f7-2572fbc2002d)

## Related Tickets

Closes #279
  • Loading branch information
nilsreichardt authored May 5, 2024
1 parent 66851a3 commit 0030a7c
Show file tree
Hide file tree
Showing 39 changed files with 1,599 additions and 154 deletions.
23 changes: 19 additions & 4 deletions app/lib/dashboard/bloc/build_lesson_views.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

part of 'dashboard_bloc.dart';

List<LessonView> _buildSortedViews(LessonDataSnapshot lessonsSnapshot) {
List<LessonView> _buildSortedViews(
LessonDataSnapshot lessonsSnapshot, Date date) {
GroupInfo? groupInfoOf(Lesson lesson) =>
lessonsSnapshot.groupInfos[lesson.groupID];

Expand All @@ -17,25 +18,39 @@ List<LessonView> _buildSortedViews(LessonDataSnapshot lessonsSnapshot) {

final views = [
for (final lesson in lessons)
_buildLessonView(lesson, groupInfo: groupInfoOf(lesson))
if (lesson.getSubstitutionFor(date) is! LessonCanceledSubstitution)
_buildLessonView(
lesson,
groupInfo: groupInfoOf(lesson),
date: date,
)
];

return views;
}

LessonView _buildLessonView(Lesson lesson, {GroupInfo? groupInfo}) {
LessonView _buildLessonView(
Lesson lesson, {
GroupInfo? groupInfo,
required Date date,
}) {
final timeline = _getTimeStatus(lesson.startTime, lesson.endTime);
final substitution = lesson.getSubstitutionFor(date);
final newLocation = substitution is LocationChangedSubstitution
? substitution.newLocation
: null;
return LessonView(
start: lesson.startTime.toString(),
end: lesson.endTime.toString(),
lesson: lesson,
room: lesson.place,
room: newLocation ?? lesson.place,
design: groupInfo?.design ?? Design.standard(),
abbreviation: groupInfo?.abbreviation ?? "",
timeStatus: timeline,
percentTimePassed:
_getPercentTimePassed(lesson.startTime, lesson.endTime, timeline),
periodNumber: lesson.periodNumber.toString(),
date: date,
);
}

Expand Down
3 changes: 2 additions & 1 deletion app/lib/dashboard/bloc/dashboard_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import 'package:sharezone/dashboard/timetable/lesson_view.dart';
import 'package:sharezone/timetable/src/bloc/timetable_bloc.dart';
import 'package:sharezone/timetable/src/models/lesson.dart';
import 'package:sharezone/timetable/src/models/lesson_data_snapshot.dart';
import 'package:sharezone/timetable/src/models/substitution.dart';
import 'package:sharezone/timetable/src/widgets/events/event_view.dart';
import 'package:sharezone/util/api/blackboard_api.dart';
import 'package:sharezone/util/api/course_gateway.dart';
Expand Down Expand Up @@ -103,7 +104,7 @@ class DashboardBloc extends BlocBase {
// Die Views werden jede Sekunde neu gebaut, damit der "Ablauf" der
// Stunde in Echtzeit angezeigt wird (ausfaden der Stunden).
.repeatEvery(const Duration(seconds: 1))
.map(_buildSortedViews);
.map((v) => _buildSortedViews(v, Date.fromDateTime(clock.now())));

final subscription = viewsStream.listen(_lessonViewsSubject.add);

Expand Down
10 changes: 7 additions & 3 deletions app/lib/dashboard/timetable/lesson_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ class _LessonCard extends StatelessWidget {
hasAlreadyTakenPlace:
view.timeStatus == LessonTimeStatus.hasAlreadyTakenPlace,
child: CustomCard.roundVertical(
onTap: () => showLessonModelSheet(context, view.lesson, view.design),
onTap: () => showLessonModelSheet(
context,
view.lesson,
view.date,
view.design,
),
onLongPress: () => onLessonLongPress(context, view.lesson),
size: isNow ? const Size(150, 65) : const Size(125, 60),
size: isNow ? const Size(150, 65) : const Size(135, 60),
child: Stack(
children: <Widget>[
if (isNow)
Expand All @@ -48,7 +53,6 @@ class _LessonCard extends StatelessWidget {
Align(
child: Column(
children: <Widget>[
SizedBox(height: isNow ? 12 : 6),
_LessonNumber(view.periodNumber),
SizedBox(height: isNow ? 8 : 3),
CircleAvatar(
Expand Down
3 changes: 3 additions & 0 deletions app/lib/dashboard/timetable/lesson_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//
// SPDX-License-Identifier: EUPL-1.2

import 'package:date/date.dart';
import 'package:design/design.dart';
import 'package:sharezone/timetable/src/models/lesson.dart';

Expand All @@ -16,13 +17,15 @@ class LessonView {
final String? room, periodNumber;
final Design design;
final Lesson lesson;
final Date date;

final LessonTimeStatus timeStatus;

/// Gibt an, wie viel Prozent (0.0 - 1.0) der Stunde schon vorbei ist.
final double percentTimePassed;

LessonView({
required this.date,
required this.start,
required this.end,
required this.room,
Expand Down
5 changes: 5 additions & 0 deletions app/lib/main/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@
String? kDevelopmentStageOrNull =
kDevelopmentStage == "" ? null : kDevelopmentStage;
const kDevelopmentStage = String.fromEnvironment('DEVELOPMENT_STAGE');

const isBetaStage = kDevelopmentStage == 'BETA';
const isAlphaStage = kDevelopmentStage == 'ALPHA';
const isPreviewStage = kDevelopmentStage == 'PREVIEW';
const isStableStage = kDevelopmentStage == 'STABLE';
11 changes: 11 additions & 0 deletions app/lib/main/sharezone_bloc_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'package:firebase_hausaufgabenheft_logik/firebase_hausaufgabenheft_logik_
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:group_domain_implementation/group_domain_accessors_implementation.dart';
import 'package:hausaufgabenheft_logik/hausaufgabenheft_logik_lehrer.dart';
import 'package:hausaufgabenheft_logik/hausaufgabenheft_logik_setup.dart';
import 'package:holidays/holidays.dart' hide State;
Expand Down Expand Up @@ -120,6 +121,7 @@ import 'package:sharezone/timetable/src/bloc/timetable_bloc.dart';
import 'package:sharezone/timetable/src/models/lesson_length/lesson_length_cache.dart';
import 'package:sharezone/timetable/timetable_add/bloc/timetable_add_bloc_dependencies.dart';
import 'package:sharezone/timetable/timetable_add/bloc/timetable_add_bloc_factory.dart';
import 'package:sharezone/timetable/timetable_page/lesson/substitution_controller.dart';
import 'package:sharezone/timetable/timetable_page/school_class_filter/school_class_filter_analytics.dart';
import 'package:sharezone/util/api.dart';
import 'package:sharezone/util/cache/key_value_store.dart';
Expand Down Expand Up @@ -473,6 +475,15 @@ class _SharezoneBlocProvidersState extends State<SharezoneBlocProviders> {
gradesService: gradesService,
coursesStream: () => api.course.streamCourses(),
),
),
Provider(
create: (context) => SubstitutionController(
gateway: api.timetable,
analytics: analytics,
userId: api.userId,
courseMemberAccessor:
FirestoreCourseMemberAccessor(api.references.firestore),
),
)
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ enum SharezonePlusFeature {
viewPastEvents,
homeworkDueDateChips,
iCalLinks,
substitutions,
}

void trySetSharezonePlusAnalyticsUserProperties(Analytics analytics,
Expand Down
12 changes: 7 additions & 5 deletions app/lib/timetable/src/logic/timetable_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ class TimetableBuilder {
),
]));

entries.addAll(filteredLessons.map((lesson) => _buildElementForLesson(
date,
lesson,
propertiesMap[lesson.lessonID]!,
)));
entries.addAll(filteredLessons.map((lesson) {
return _buildElementForLesson(
date,
lesson,
propertiesMap[lesson.lessonID]!,
);
}));
entries.addAll(filteredEvents.map((event) => _buildElementForEvent(
event,
propertiesMap[event.eventID] ?? TimetableElementProperties.standard,
Expand Down
26 changes: 26 additions & 0 deletions app/lib/timetable/src/models/lesson.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
// SPDX-License-Identifier: EUPL-1.2

import 'package:cloud_firestore_helper/cloud_firestore_helper.dart';
import 'package:collection/collection.dart';
import 'package:date/date.dart';
import 'package:date/weekday.dart';
import 'package:date/weektype.dart';
import 'package:group_domain_models/group_domain_models.dart';
import 'package:sharezone/timetable/src/models/lesson_length/lesson_length.dart';
import 'package:sharezone/timetable/src/models/substitution.dart';
import 'package:sharezone/timetable/src/models/substitution_id.dart';
import 'package:time/time.dart';

class Lesson {
Expand All @@ -31,6 +34,7 @@ class Lesson {
final WeekDay weekday;
final WeekType weektype;
final String? teacher, place;
final Map<SubstitutionId, Substitution> substitutions;
LessonLength get length => calculateLessonLength(startTime, endTime);

Lesson({
Expand All @@ -47,6 +51,7 @@ class Lesson {
required this.weektype,
required this.teacher,
required this.place,
this.substitutions = const {},
});

factory Lesson.fromData(Map<String, dynamic> data, {required String id}) {
Expand All @@ -64,6 +69,15 @@ class Lesson {
weektype: WeekType.values.byName(data['weektype'] as String),
teacher: data['teacher'] as String?,
place: data['place'] as String?,
substitutions: data['substitutions'] == null
? const {}
: decodeMapAdvanced(
data['substitutions'],
(key, value) {
final id = SubstitutionId(key);
return MapEntry(id, Substitution.fromData(value, id: id));
},
),
);
}

Expand Down Expand Up @@ -97,6 +111,7 @@ class Lesson {
WeekType? weektype,
String? teacher,
String? place,
Map<SubstitutionId, Substitution>? substitutions,
}) {
return Lesson(
createdOn: createdOn ?? this.createdOn,
Expand All @@ -112,6 +127,17 @@ class Lesson {
weektype: weektype ?? this.weektype,
teacher: teacher ?? this.teacher,
place: place ?? this.place,
substitutions: substitutions ?? this.substitutions,
);
}

/// Returns the substitution for the given [date].
///
/// If there is no substitution for the given [date], `null` will be returned.
Substitution? getSubstitutionFor(Date date) {
return substitutions.values.firstWhereOrNull(
(substitution) =>
substitution.date == date && substitution.isDeleted == false,
);
}

Expand Down
Loading

0 comments on commit 0030a7c

Please sign in to comment.