implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions.
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
|
import 'get_capabilities_response.dart';
|
||||||
|
|
||||||
|
/// Fetches the current user's mobile capability flags from
|
||||||
|
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
|
||||||
|
/// so the bearer token is attached automatically.
|
||||||
|
class GetCapabilities {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
Future<CapabilitiesResponse> run() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('me/capabilities'),
|
||||||
|
);
|
||||||
|
return CapabilitiesResponse.fromJson(response.data!);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'get_capabilities_response.g.dart';
|
||||||
|
|
||||||
|
/// Slimmed-down capability flags the mobile UI gates features on. The backend
|
||||||
|
/// only returns the handful of permissions the app actually consumes — not a
|
||||||
|
/// full permission dump. Unknown/missing fields default to `false` so a stale
|
||||||
|
/// client never accidentally enables a feature it shouldn't.
|
||||||
|
@JsonSerializable()
|
||||||
|
class CapabilitiesResponse {
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
final bool viewForeignTimetables;
|
||||||
|
|
||||||
|
CapabilitiesResponse({required this.viewForeignTimetables});
|
||||||
|
|
||||||
|
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CapabilitiesResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$CapabilitiesResponseToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'get_capabilities_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => CapabilitiesResponse(
|
||||||
|
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||||
|
CapabilitiesResponse instance,
|
||||||
|
) => <String, dynamic>{'viewForeignTimetables': instance.viewForeignTimetables};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
|
import 'timetable_get_classes_response.dart';
|
||||||
|
|
||||||
|
class TimetableGetClasses {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
Future<TimetableGetClassesResponse> run() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<List<dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
|
||||||
|
);
|
||||||
|
final list = response.data!
|
||||||
|
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
return TimetableGetClassesResponse(result: list);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_response.dart';
|
||||||
|
|
||||||
|
part 'timetable_get_classes_response.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class McTimetableClass {
|
||||||
|
final int id;
|
||||||
|
final String shortName;
|
||||||
|
final String longName;
|
||||||
|
|
||||||
|
McTimetableClass({
|
||||||
|
required this.id,
|
||||||
|
required this.shortName,
|
||||||
|
required this.longName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory McTimetableClass.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$McTimetableClassFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$McTimetableClassToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class TimetableGetClassesResponse extends ApiResponse {
|
||||||
|
final List<McTimetableClass> result;
|
||||||
|
|
||||||
|
TimetableGetClassesResponse({required this.result});
|
||||||
|
|
||||||
|
factory TimetableGetClassesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TimetableGetClassesResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$TimetableGetClassesResponseToJson(this);
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'timetable_get_classes_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) =>
|
||||||
|
McTimetableClass(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
shortName: json['shortName'] as String,
|
||||||
|
longName: json['longName'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'shortName': instance.shortName,
|
||||||
|
'longName': instance.longName,
|
||||||
|
};
|
||||||
|
|
||||||
|
TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) =>
|
||||||
|
TimetableGetClassesResponse(
|
||||||
|
result: (json['result'] as List<dynamic>)
|
||||||
|
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TimetableGetClassesResponseToJson(
|
||||||
|
TimetableGetClassesResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'result': instance.result.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/// A concrete, selectable timetable element: its type, WebUntis element id and
|
||||||
|
/// a human-readable label (room short name, abbreviated student name, …). Used
|
||||||
|
/// to hand a picker selection back to the timetable view and to drive the
|
||||||
|
/// inline foreign-plan rendering.
|
||||||
|
typedef TimetableElementRef = ({TimetableElementType type, int id, String label});
|
||||||
|
|
||||||
|
/// The four kinds of timetable elements whose schedule can be requested via
|
||||||
|
/// `timetable/{type}/{id}`. `schoolClass` is named to avoid the reserved Dart
|
||||||
|
/// keyword `class`; its [pathSegment] maps back to the backend's `class`.
|
||||||
|
enum TimetableElementType {
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
room,
|
||||||
|
schoolClass;
|
||||||
|
|
||||||
|
/// Path segment used in the backend timetable endpoint URL.
|
||||||
|
String get pathSegment {
|
||||||
|
switch (this) {
|
||||||
|
case TimetableElementType.student:
|
||||||
|
return 'student';
|
||||||
|
case TimetableElementType.teacher:
|
||||||
|
return 'teacher';
|
||||||
|
case TimetableElementType.room:
|
||||||
|
return 'room';
|
||||||
|
case TimetableElementType.schoolClass:
|
||||||
|
return 'class';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singular German label for the UI (picker segments, hints).
|
||||||
|
String get label {
|
||||||
|
switch (this) {
|
||||||
|
case TimetableElementType.student:
|
||||||
|
return 'Schüler';
|
||||||
|
case TimetableElementType.teacher:
|
||||||
|
return 'Lehrer';
|
||||||
|
case TimetableElementType.room:
|
||||||
|
return 'Raum';
|
||||||
|
case TimetableElementType.schoolClass:
|
||||||
|
return 'Klasse';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
|
import '../timetable_get_week/timetable_get_week_response.dart';
|
||||||
|
import 'timetable_element_type.dart';
|
||||||
|
|
||||||
|
/// Fetches a foreign element's weekly timetable from
|
||||||
|
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
|
||||||
|
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
|
||||||
|
class TimetableGetElementWeek {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
Future<TimetableGetWeekResponse> run({
|
||||||
|
required TimetableElementType type,
|
||||||
|
required int id,
|
||||||
|
required DateTime from,
|
||||||
|
required DateTime until,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
|
||||||
|
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||||
|
);
|
||||||
|
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _format(DateTime d) =>
|
||||||
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
|
import 'timetable_get_students_response.dart';
|
||||||
|
|
||||||
|
class TimetableGetStudents {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
Future<TimetableGetStudentsResponse> run() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<List<dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('timetable/elements/students'),
|
||||||
|
);
|
||||||
|
final list = response.data!
|
||||||
|
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
return TimetableGetStudentsResponse(result: list);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_response.dart';
|
||||||
|
|
||||||
|
part 'timetable_get_students_response.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class McTimetableStudent {
|
||||||
|
final int id;
|
||||||
|
final String firstName;
|
||||||
|
final String lastName;
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
McTimetableStudent({
|
||||||
|
required this.id,
|
||||||
|
required this.firstName,
|
||||||
|
required this.lastName,
|
||||||
|
required this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory McTimetableStudent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$McTimetableStudentFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$McTimetableStudentToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class TimetableGetStudentsResponse extends ApiResponse {
|
||||||
|
final List<McTimetableStudent> result;
|
||||||
|
|
||||||
|
TimetableGetStudentsResponse({required this.result});
|
||||||
|
|
||||||
|
factory TimetableGetStudentsResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TimetableGetStudentsResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$TimetableGetStudentsResponseToJson(this);
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'timetable_get_students_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
McTimetableStudent _$McTimetableStudentFromJson(Map<String, dynamic> json) =>
|
||||||
|
McTimetableStudent(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
firstName: json['firstName'] as String,
|
||||||
|
lastName: json['lastName'] as String,
|
||||||
|
displayName: json['displayName'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$McTimetableStudentToJson(McTimetableStudent instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'firstName': instance.firstName,
|
||||||
|
'lastName': instance.lastName,
|
||||||
|
'displayName': instance.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
TimetableGetStudentsResponse _$TimetableGetStudentsResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) =>
|
||||||
|
TimetableGetStudentsResponse(
|
||||||
|
result: (json['result'] as List<dynamic>)
|
||||||
|
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TimetableGetStudentsResponseToJson(
|
||||||
|
TimetableGetStudentsResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'result': instance.result.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
|
import 'timetable_get_teachers_response.dart';
|
||||||
|
|
||||||
|
class TimetableGetTeachers {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
|
||||||
|
|
||||||
|
Future<TimetableGetTeachersResponse> run() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get<List<dynamic>>(
|
||||||
|
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
|
||||||
|
);
|
||||||
|
final list = response.data!
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return TimetableGetTeachersResponse(result: list);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../../api_response.dart';
|
||||||
|
|
||||||
|
part 'timetable_get_teachers_response.g.dart';
|
||||||
|
|
||||||
|
/// Picker list entry for a teacher. Named `...Element` to avoid colliding with
|
||||||
|
/// `McTimetableteacher` from the week response, which models the teacher *of a
|
||||||
|
/// lesson* (with substitution fields) rather than a selectable element.
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class McTimetableTeacherElement {
|
||||||
|
final int id;
|
||||||
|
final String shortName;
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
McTimetableTeacherElement({
|
||||||
|
required this.id,
|
||||||
|
required this.shortName,
|
||||||
|
required this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory McTimetableTeacherElement.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$McTimetableTeacherElementFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$McTimetableTeacherElementToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class TimetableGetTeachersResponse extends ApiResponse {
|
||||||
|
final List<McTimetableTeacherElement> result;
|
||||||
|
|
||||||
|
TimetableGetTeachersResponse({required this.result});
|
||||||
|
|
||||||
|
factory TimetableGetTeachersResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TimetableGetTeachersResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$TimetableGetTeachersResponseToJson(this);
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'timetable_get_teachers_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
McTimetableTeacherElement _$McTimetableTeacherElementFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => McTimetableTeacherElement(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
shortName: json['shortName'] as String,
|
||||||
|
displayName: json['displayName'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$McTimetableTeacherElementToJson(
|
||||||
|
McTimetableTeacherElement instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'shortName': instance.shortName,
|
||||||
|
'displayName': instance.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
TimetableGetTeachersResponse _$TimetableGetTeachersResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) =>
|
||||||
|
TimetableGetTeachersResponse(
|
||||||
|
result: (json['result'] as List<dynamic>)
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TimetableGetTeachersResponseToJson(
|
||||||
|
TimetableGetTeachersResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'headers': ?instance.headers,
|
||||||
|
'result': instance.result.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
+18
-1
@@ -30,6 +30,7 @@ import 'share_intent/share_intent_listener.dart';
|
|||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
|
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
import 'state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
@@ -159,6 +160,7 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
|
||||||
|
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
|
||||||
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
|
||||||
BlocProvider<ChatBloc>(
|
BlocProvider<ChatBloc>(
|
||||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
@@ -193,7 +195,13 @@ class _MainState extends State<Main> {
|
|||||||
accountBloc.setStatus(
|
accountBloc.setStatus(
|
||||||
value ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
value ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
||||||
);
|
);
|
||||||
if (value) _scheduleSessionValidation(accountBloc);
|
if (value) {
|
||||||
|
_scheduleSessionValidation(accountBloc);
|
||||||
|
// Cold start while already logged in: the account status doesn't
|
||||||
|
// change, so the loggedIn listener below never fires — refresh
|
||||||
|
// capabilities here.
|
||||||
|
unawaited(context.read<CapabilitiesCubit>().load());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +270,11 @@ class _MainState extends State<Main> {
|
|||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
listener: (context, accountState) {
|
listener: (context, accountState) {
|
||||||
|
// Fresh login (loggedOut -> loggedIn): pull capability flags
|
||||||
|
// for the newly authenticated user.
|
||||||
|
if (accountState.status == AccountStatus.loggedIn) {
|
||||||
|
unawaited(context.read<CapabilitiesCubit>().load());
|
||||||
|
}
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
if (accountState.status != AccountStatus.loggedOut) return;
|
||||||
// A pending share would otherwise survive logout and be
|
// A pending share would otherwise survive logout and be
|
||||||
// re-applied after re-login with file paths the OS may
|
// re-applied after re-login with file paths the OS may
|
||||||
@@ -283,6 +296,7 @@ class _MainState extends State<Main> {
|
|||||||
final chatListBloc = context.read<ChatListBloc>();
|
final chatListBloc = context.read<ChatListBloc>();
|
||||||
final chatBloc = context.read<ChatBloc>();
|
final chatBloc = context.read<ChatBloc>();
|
||||||
final breakerBloc = context.read<BreakerBloc>();
|
final breakerBloc = context.read<BreakerBloc>();
|
||||||
|
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||||
// Defer the actual wipe until after this frame so the
|
// Defer the actual wipe until after this frame so the
|
||||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||||
// is already torn down. Resetting blocs while App is
|
// is already torn down. Resetting blocs while App is
|
||||||
@@ -295,6 +309,7 @@ class _MainState extends State<Main> {
|
|||||||
chatListBloc: chatListBloc,
|
chatListBloc: chatListBloc,
|
||||||
chatBloc: chatBloc,
|
chatBloc: chatBloc,
|
||||||
breakerBloc: breakerBloc,
|
breakerBloc: breakerBloc,
|
||||||
|
capabilitiesCubit: capabilitiesCubit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -339,6 +354,7 @@ Future<void> _wipeUserState({
|
|||||||
required ChatListBloc chatListBloc,
|
required ChatListBloc chatListBloc,
|
||||||
required ChatBloc chatBloc,
|
required ChatBloc chatBloc,
|
||||||
required BreakerBloc breakerBloc,
|
required BreakerBloc breakerBloc,
|
||||||
|
required CapabilitiesCubit capabilitiesCubit,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Reset user-data blocs whose tree is no longer mounted after the
|
// Reset user-data blocs whose tree is no longer mounted after the
|
||||||
@@ -351,6 +367,7 @@ Future<void> _wipeUserState({
|
|||||||
chatListBloc.reset(),
|
chatListBloc.reset(),
|
||||||
chatBloc.reset(),
|
chatBloc.reset(),
|
||||||
breakerBloc.reset(),
|
breakerBloc.reset(),
|
||||||
|
capabilitiesCubit.reset(),
|
||||||
]);
|
]);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
|
|
||||||
import '../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
import '../notification/notification_tasks.dart';
|
import '../notification/notification_tasks.dart';
|
||||||
@@ -14,6 +15,7 @@ import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
|||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||||
import '../view/pages/files/files.dart';
|
import '../view/pages/files/files.dart';
|
||||||
|
import '../view/pages/foreign_timetable/element_picker_page.dart';
|
||||||
import '../view/pages/marianum_message/marianum_message_view.dart';
|
import '../view/pages/marianum_message/marianum_message_view.dart';
|
||||||
import '../view/pages/more/feedback/feedback_dialog.dart';
|
import '../view/pages/more/feedback/feedback_dialog.dart';
|
||||||
import '../view/pages/more/roomplan/roomplan.dart';
|
import '../view/pages/more/roomplan/roomplan.dart';
|
||||||
@@ -71,6 +73,24 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the picker for choosing a foreign timetable element and resolves to
|
||||||
|
/// the selected element (or null if dismissed). The timetable view renders
|
||||||
|
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
||||||
|
/// capability at the call site.
|
||||||
|
static Future<TimetableElementRef?> openElementPicker(
|
||||||
|
BuildContext context,
|
||||||
|
) async {
|
||||||
|
// pushScreen casts its internal MaterialPageRoute to `Route<T>`, which
|
||||||
|
// blows up for a concrete (record) T — so push untyped (T = dynamic, like
|
||||||
|
// every other caller) and cast the popped result ourselves.
|
||||||
|
final result = await pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: const ElementPickerPage(),
|
||||||
|
);
|
||||||
|
return result as TimetableElementRef?;
|
||||||
|
}
|
||||||
|
|
||||||
static void openMarianumMessage(
|
static void openMarianumMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String basePath,
|
String basePath,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
|
||||||
|
import 'capabilities_state.dart';
|
||||||
|
|
||||||
|
/// Holds the current user's mobile capability flags. Hydrated so the last
|
||||||
|
/// known state is available immediately on cold start (no feature-flicker)
|
||||||
|
/// and offline. [load] refreshes it from the server after login.
|
||||||
|
class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||||
|
CapabilitiesCubit() : super(const CapabilitiesState());
|
||||||
|
|
||||||
|
bool get canViewForeignTimetables => state.viewForeignTimetables;
|
||||||
|
|
||||||
|
/// Refreshes capabilities from the server. On any failure (endpoint not yet
|
||||||
|
/// live, network error, 4xx) the previously hydrated flags are kept but the
|
||||||
|
/// state is marked `loaded` — a failed fetch never silently grants a
|
||||||
|
/// capability, and an offline launch keeps whatever was cached.
|
||||||
|
Future<void> load() async {
|
||||||
|
try {
|
||||||
|
final response = await GetCapabilities().run();
|
||||||
|
emit(
|
||||||
|
CapabilitiesState(
|
||||||
|
viewForeignTimetables: response.viewForeignTimetables,
|
||||||
|
loaded: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to load capabilities: $e');
|
||||||
|
emit(state.copyWith(loaded: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reset() async => emit(const CapabilitiesState());
|
||||||
|
|
||||||
|
@override
|
||||||
|
CapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return CapabilitiesState.fromJson(json);
|
||||||
|
} catch (_) {
|
||||||
|
return const CapabilitiesState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toJson(CapabilitiesState state) => state.toJson();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'capabilities_state.freezed.dart';
|
||||||
|
part 'capabilities_state.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CapabilitiesState with _$CapabilitiesState {
|
||||||
|
const factory CapabilitiesState({
|
||||||
|
@Default(false) bool viewForeignTimetables,
|
||||||
|
// Whether a capability response (or a definitive failure) has been
|
||||||
|
// observed at least once this session. Lets the UI distinguish "still
|
||||||
|
// unknown" from "confirmed not allowed".
|
||||||
|
@Default(false) bool loaded,
|
||||||
|
}) = _CapabilitiesState;
|
||||||
|
|
||||||
|
factory CapabilitiesState.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CapabilitiesStateFromJson(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'capabilities_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$CapabilitiesState {
|
||||||
|
|
||||||
|
bool get viewForeignTimetables;// Whether a capability response (or a definitive failure) has been
|
||||||
|
// observed at least once this session. Lets the UI distinguish "still
|
||||||
|
// unknown" from "confirmed not allowed".
|
||||||
|
bool get loaded;
|
||||||
|
/// Create a copy of CapabilitiesState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStateCopyWithImpl<CapabilitiesState>(this as CapabilitiesState, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this CapabilitiesState to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $CapabilitiesStateCopyWith<$Res> {
|
||||||
|
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
bool viewForeignTimetables, bool loaded
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$CapabilitiesStateCopyWithImpl<$Res>
|
||||||
|
implements $CapabilitiesStateCopyWith<$Res> {
|
||||||
|
_$CapabilitiesStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final CapabilitiesState _self;
|
||||||
|
final $Res Function(CapabilitiesState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of CapabilitiesState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [CapabilitiesState].
|
||||||
|
extension CapabilitiesStatePatterns on CapabilitiesState {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _CapabilitiesState value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _CapabilitiesState value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _CapabilitiesState value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState() when $default != null:
|
||||||
|
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState():
|
||||||
|
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool loaded)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _CapabilitiesState() when $default != null:
|
||||||
|
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _CapabilitiesState implements CapabilitiesState {
|
||||||
|
const _CapabilitiesState({this.viewForeignTimetables = false, this.loaded = false});
|
||||||
|
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
|
||||||
|
|
||||||
|
@override@JsonKey() final bool viewForeignTimetables;
|
||||||
|
// Whether a capability response (or a definitive failure) has been
|
||||||
|
// observed at least once this session. Lets the UI distinguish "still
|
||||||
|
// unknown" from "confirmed not allowed".
|
||||||
|
@override@JsonKey() final bool loaded;
|
||||||
|
|
||||||
|
/// Create a copy of CapabilitiesState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$CapabilitiesStateCopyWith<_CapabilitiesState> get copyWith => __$CapabilitiesStateCopyWithImpl<_CapabilitiesState>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$CapabilitiesStateToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesStateCopyWith<$Res> {
|
||||||
|
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
bool viewForeignTimetables, bool loaded
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$CapabilitiesStateCopyWithImpl<$Res>
|
||||||
|
implements _$CapabilitiesStateCopyWith<$Res> {
|
||||||
|
__$CapabilitiesStateCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _CapabilitiesState _self;
|
||||||
|
final $Res Function(_CapabilitiesState) _then;
|
||||||
|
|
||||||
|
/// Create a copy of CapabilitiesState
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
|
||||||
|
return _then(_CapabilitiesState(
|
||||||
|
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'capabilities_state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
|
||||||
|
_CapabilitiesState(
|
||||||
|
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||||
|
loaded: json['loaded'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||||
|
'loaded': instance.loaded,
|
||||||
|
};
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
|
import '../../../../../extensions/date_time.dart';
|
||||||
|
import '../../../infrastructure/loadable_state/loadable_state.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
|
import '../../timetable/bloc/timetable_event.dart';
|
||||||
|
import '../../timetable/bloc/timetable_state.dart';
|
||||||
|
import '../repository/foreign_timetable_repository.dart';
|
||||||
|
|
||||||
|
/// Drives a foreign element's timetable. Mirrors the week-loading and
|
||||||
|
/// week-navigation logic of `TimetableBloc` but (a) loads weeks from the
|
||||||
|
/// element endpoint, (b) carries no custom events, and (c) does not persist —
|
||||||
|
/// it is created per opened page and recreated for every selected element.
|
||||||
|
///
|
||||||
|
/// It reuses [TimetableState] verbatim so the existing render pipeline works
|
||||||
|
/// unchanged; `customEvents` simply stays null (the foreign view uses an
|
||||||
|
/// `isReady` predicate that ignores it).
|
||||||
|
class ForeignTimetableBloc
|
||||||
|
extends
|
||||||
|
LoadableHydratedBloc<
|
||||||
|
TimetableEvent,
|
||||||
|
TimetableState,
|
||||||
|
ForeignTimetableRepository
|
||||||
|
> {
|
||||||
|
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
|
||||||
|
|
||||||
|
final TimetableElementType type;
|
||||||
|
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
|
||||||
|
// `String get id` (the storage key), which a plain `int id` would illegally
|
||||||
|
// override.
|
||||||
|
final int elementId;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
ForeignTimetableBloc({
|
||||||
|
required this.type,
|
||||||
|
required this.elementId,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ForeignTimetableRepository repository() => ForeignTimetableRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TimetableState fromNothing() {
|
||||||
|
final reference = DateTime.now().addDays(2);
|
||||||
|
return TimetableState(
|
||||||
|
startDate: _startOfWeek(reference),
|
||||||
|
endDate: _endOfWeek(reference),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistence is disabled: this bloc is page-scoped and element-specific, so
|
||||||
|
// there is nothing worth restoring across launches. Returning null from
|
||||||
|
// toJson means HydratedBloc never writes anything; fromJson ignores any
|
||||||
|
// legacy payload and starts fresh.
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
|
||||||
|
const LoadableState(
|
||||||
|
isLoading: true,
|
||||||
|
data: null,
|
||||||
|
lastFetch: null,
|
||||||
|
reFetch: null,
|
||||||
|
error: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
TimetableState fromStorage(Map<String, dynamic> json) => fromNothing();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic>? toStorage(TimetableState state) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> gatherData() async {
|
||||||
|
final initial = innerState ?? fromNothing();
|
||||||
|
|
||||||
|
Object? firstError;
|
||||||
|
void recordError(Object e) {
|
||||||
|
firstError ??= e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait([
|
||||||
|
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
|
||||||
|
_loadStaticReferenceData(onError: recordError),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (firstError != null) throw firstError!;
|
||||||
|
|
||||||
|
add(DataGathered((s) => s));
|
||||||
|
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void changeWeek(DateTime startDate, DateTime endDate) {
|
||||||
|
final current = innerState ?? fromNothing();
|
||||||
|
if (current.startDate == startDate && current.endDate == endDate) return;
|
||||||
|
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
|
||||||
|
_loadCurrentWeek(startDate, endDate);
|
||||||
|
_prefetchAdjacentWeeks(startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetWeek() {
|
||||||
|
final reference = DateTime.now().addDays(2);
|
||||||
|
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh() => fetch();
|
||||||
|
|
||||||
|
Future<void> _loadCurrentWeek(
|
||||||
|
DateTime startDate,
|
||||||
|
DateTime endDate, {
|
||||||
|
void Function(Object)? onError,
|
||||||
|
}) async {
|
||||||
|
final requestStart = DateTime.now();
|
||||||
|
_lastWeekRequestStart = requestStart;
|
||||||
|
try {
|
||||||
|
final week = await repo.data.getElementWeek(
|
||||||
|
type,
|
||||||
|
elementId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onError: onError,
|
||||||
|
);
|
||||||
|
if (_lastWeekRequestStart.isAfter(requestStart)) return;
|
||||||
|
_writeWeekToCache(startDate, week);
|
||||||
|
} catch (e) {
|
||||||
|
log('getElementWeek error for $startDate–$endDate: $e');
|
||||||
|
onError?.call(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStaticReferenceData({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final (rooms, subjects, schoolHolidays, schoolyear) = await (
|
||||||
|
repo.data.getRooms(onError: onError),
|
||||||
|
repo.data.getSubjects(onError: onError),
|
||||||
|
repo.data.getSchoolHolidays(onError: onError),
|
||||||
|
repo.data.getCurrentSchoolyear(onError: onError),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
add(
|
||||||
|
Emit(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
rooms: rooms,
|
||||||
|
subjects: subjects,
|
||||||
|
schoolHolidays: schoolHolidays,
|
||||||
|
schoolyear: schoolyear,
|
||||||
|
dataVersion: s.dataVersion + 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
onError?.call(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final timegrid = await repo.data.getTimegrid();
|
||||||
|
add(
|
||||||
|
Emit(
|
||||||
|
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Timegrid load failure falls back to a hardcoded schedule in the UI.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
|
||||||
|
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
|
||||||
|
_prefetchWeek(start.addDays(7), end.addDays(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prefetchWeek(DateTime start, DateTime end) {
|
||||||
|
repo.data
|
||||||
|
.getElementWeek(type, elementId, start, end)
|
||||||
|
.then((week) => _writeWeekToCache(start, week))
|
||||||
|
.catchError((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
||||||
|
final key = _weekKeyFormat.format(weekStart);
|
||||||
|
add(
|
||||||
|
Emit((s) {
|
||||||
|
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
||||||
|
updated[key] = week;
|
||||||
|
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _startOfWeek(DateTime reference) {
|
||||||
|
final monday = reference.subtractDays(reference.weekday - 1);
|
||||||
|
return DateTime(monday.year, monday.month, monday.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _endOfWeek(DateTime reference) {
|
||||||
|
final friday = reference.addDays(
|
||||||
|
DateTime.daysPerWeek - reference.weekday - 2,
|
||||||
|
);
|
||||||
|
return DateTime(friday.year, friday.month, friday.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||||
|
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
|
import '../../timetable/data_provider/timetable_data_provider.dart';
|
||||||
|
|
||||||
|
/// Data access for a foreign element's timetable. The week comes from the
|
||||||
|
/// element-specific endpoint; all reference data (rooms/subjects/holidays/
|
||||||
|
/// school year/timegrid) is school-wide and identical to the user's own plan,
|
||||||
|
/// so it is delegated to the existing [TimetableDataProvider] (which already
|
||||||
|
/// caches it). Custom events are intentionally absent — they are user-private.
|
||||||
|
class ForeignTimetableDataProvider {
|
||||||
|
final TimetableDataProvider _base;
|
||||||
|
|
||||||
|
ForeignTimetableDataProvider([TimetableDataProvider? base])
|
||||||
|
: _base = base ?? TimetableDataProvider();
|
||||||
|
|
||||||
|
Future<TimetableGetWeekResponse> getElementWeek(
|
||||||
|
TimetableElementType type,
|
||||||
|
int id,
|
||||||
|
DateTime startDate,
|
||||||
|
DateTime endDate, {
|
||||||
|
void Function(Object)? onError,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await TimetableGetElementWeek().run(
|
||||||
|
type: type,
|
||||||
|
id: id,
|
||||||
|
from: startDate,
|
||||||
|
until: endDate,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
onError?.call(e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<TimetableGetRoomsResponse> getRooms({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) => _base.getRooms(onError: onError, renew: renew);
|
||||||
|
|
||||||
|
Future<TimetableGetSubjectsResponse> getSubjects({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) => _base.getSubjects(onError: onError, renew: renew);
|
||||||
|
|
||||||
|
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
|
||||||
|
|
||||||
|
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
|
||||||
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
|
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
|
||||||
|
|
||||||
|
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
|
||||||
|
_base.getTimegrid(renew: renew);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import '../../../infrastructure/repository/repository.dart';
|
||||||
|
import '../../timetable/bloc/timetable_state.dart';
|
||||||
|
import '../data_provider/foreign_timetable_data_provider.dart';
|
||||||
|
|
||||||
|
class ForeignTimetableRepository extends Repository<TimetableState> {
|
||||||
|
final ForeignTimetableDataProvider _provider;
|
||||||
|
|
||||||
|
ForeignTimetableRepository([ForeignTimetableDataProvider? provider])
|
||||||
|
: _provider = provider ?? ForeignTimetableDataProvider();
|
||||||
|
|
||||||
|
ForeignTimetableDataProvider get data => _provider;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import 'holidays_settings.dart';
|
|||||||
import 'modules_settings.dart';
|
import 'modules_settings.dart';
|
||||||
import 'notification_settings.dart';
|
import 'notification_settings.dart';
|
||||||
import 'talk_settings.dart';
|
import 'talk_settings.dart';
|
||||||
|
import 'timetable_favorites_settings.dart';
|
||||||
import 'timetable_settings.dart';
|
import 'timetable_settings.dart';
|
||||||
|
|
||||||
part 'settings.g.dart';
|
part 'settings.g.dart';
|
||||||
@@ -22,6 +23,7 @@ class Settings {
|
|||||||
|
|
||||||
ModulesSettings modulesSettings;
|
ModulesSettings modulesSettings;
|
||||||
TimetableSettings timetableSettings;
|
TimetableSettings timetableSettings;
|
||||||
|
TimetableFavoritesSettings timetableFavoritesSettings;
|
||||||
TalkSettings talkSettings;
|
TalkSettings talkSettings;
|
||||||
ChatBackgroundSettings chatBackgroundSettings;
|
ChatBackgroundSettings chatBackgroundSettings;
|
||||||
FileSettings fileSettings;
|
FileSettings fileSettings;
|
||||||
@@ -36,6 +38,7 @@ class Settings {
|
|||||||
required this.devToolsEnabled,
|
required this.devToolsEnabled,
|
||||||
required this.modulesSettings,
|
required this.modulesSettings,
|
||||||
required this.timetableSettings,
|
required this.timetableSettings,
|
||||||
|
required this.timetableFavoritesSettings,
|
||||||
required this.talkSettings,
|
required this.talkSettings,
|
||||||
required this.chatBackgroundSettings,
|
required this.chatBackgroundSettings,
|
||||||
required this.fileSettings,
|
required this.fileSettings,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings(
|
|||||||
timetableSettings: TimetableSettings.fromJson(
|
timetableSettings: TimetableSettings.fromJson(
|
||||||
json['timetableSettings'] as Map<String, dynamic>,
|
json['timetableSettings'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
|
timetableFavoritesSettings: TimetableFavoritesSettings.fromJson(
|
||||||
|
json['timetableFavoritesSettings'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
talkSettings: TalkSettings.fromJson(
|
talkSettings: TalkSettings.fromJson(
|
||||||
json['talkSettings'] as Map<String, dynamic>,
|
json['talkSettings'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
@@ -46,6 +49,7 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
|||||||
'devToolsEnabled': instance.devToolsEnabled,
|
'devToolsEnabled': instance.devToolsEnabled,
|
||||||
'modulesSettings': instance.modulesSettings.toJson(),
|
'modulesSettings': instance.modulesSettings.toJson(),
|
||||||
'timetableSettings': instance.timetableSettings.toJson(),
|
'timetableSettings': instance.timetableSettings.toJson(),
|
||||||
|
'timetableFavoritesSettings': instance.timetableFavoritesSettings.toJson(),
|
||||||
'talkSettings': instance.talkSettings.toJson(),
|
'talkSettings': instance.talkSettings.toJson(),
|
||||||
'chatBackgroundSettings': instance.chatBackgroundSettings.toJson(),
|
'chatBackgroundSettings': instance.chatBackgroundSettings.toJson(),
|
||||||
'fileSettings': instance.fileSettings.toJson(),
|
'fileSettings': instance.fileSettings.toJson(),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
|
|
||||||
|
part 'timetable_favorites_settings.g.dart';
|
||||||
|
|
||||||
|
/// A timetable element the user starred for quick access from the picker.
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class FavoriteTimetableElement {
|
||||||
|
final TimetableElementType type;
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
FavoriteTimetableElement({
|
||||||
|
required this.type,
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FavoriteTimetableElement.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$FavoriteTimetableElementFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$FavoriteTimetableElementToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class TimetableFavoritesSettings {
|
||||||
|
List<FavoriteTimetableElement> favorites;
|
||||||
|
|
||||||
|
TimetableFavoritesSettings({required this.favorites});
|
||||||
|
|
||||||
|
bool isFavorite(TimetableElementType type, int id) =>
|
||||||
|
favorites.any((f) => f.type == type && f.id == id);
|
||||||
|
|
||||||
|
/// Adds or removes the element in place. Returns the new favorite state
|
||||||
|
/// (`true` = now starred). Callers persist via `SettingsCubit.val(write: true)`.
|
||||||
|
bool toggle(TimetableElementType type, int id, String label) {
|
||||||
|
final existing = favorites.where((f) => f.type == type && f.id == id);
|
||||||
|
if (existing.isNotEmpty) {
|
||||||
|
favorites.removeWhere((f) => f.type == type && f.id == id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
favorites.add(FavoriteTimetableElement(type: type, id: id, label: label));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory TimetableFavoritesSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$TimetableFavoritesSettingsFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$TimetableFavoritesSettingsToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'timetable_favorites_settings.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
FavoriteTimetableElement _$FavoriteTimetableElementFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => FavoriteTimetableElement(
|
||||||
|
type: $enumDecode(_$TimetableElementTypeEnumMap, json['type']),
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
label: json['label'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$FavoriteTimetableElementToJson(
|
||||||
|
FavoriteTimetableElement instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'type': _$TimetableElementTypeEnumMap[instance.type]!,
|
||||||
|
'id': instance.id,
|
||||||
|
'label': instance.label,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$TimetableElementTypeEnumMap = {
|
||||||
|
TimetableElementType.student: 'student',
|
||||||
|
TimetableElementType.teacher: 'teacher',
|
||||||
|
TimetableElementType.room: 'room',
|
||||||
|
TimetableElementType.schoolClass: 'schoolClass',
|
||||||
|
};
|
||||||
|
|
||||||
|
TimetableFavoritesSettings _$TimetableFavoritesSettingsFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => TimetableFavoritesSettings(
|
||||||
|
favorites: (json['favorites'] as List<dynamic>)
|
||||||
|
.map((e) => FavoriteTimetableElement.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$TimetableFavoritesSettingsToJson(
|
||||||
|
TimetableFavoritesSettings instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'favorites': instance.favorites.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../api/errors/error_mapper.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
|
||||||
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../storage/timetable_favorites_settings.dart';
|
||||||
|
import '../../../utils/haptics.dart';
|
||||||
|
import '../../../widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
|
/// Full-screen picker: choose an element type (or "Alle" to search across all
|
||||||
|
/// types), filter the (potentially large) list client-side, and pick an element
|
||||||
|
/// whose timetable is then shown inline. Starred elements appear at the top for
|
||||||
|
/// quick access.
|
||||||
|
class ElementPickerPage extends StatefulWidget {
|
||||||
|
const ElementPickerPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ElementPickerPage> createState() => _ElementPickerPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||||
|
/// `null` = the "Alle" tab (search across every type). Default.
|
||||||
|
TimetableElementType? _selectedType;
|
||||||
|
String _query = '';
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
// One in-flight/resolved future per type so switching tabs (or rebuilds)
|
||||||
|
// never re-fetches a list that's already loaded.
|
||||||
|
final Map<TimetableElementType, Future<List<_PickerItem>>> _futures = {};
|
||||||
|
// Memoised combined future for the "Alle" tab; rebuilt on retry.
|
||||||
|
Future<List<_PickerItem>>? _allFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_PickerItem>> _currentFuture() {
|
||||||
|
final type = _selectedType;
|
||||||
|
if (type != null) return _loadFor(type);
|
||||||
|
return _allFuture ??= _loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_PickerItem>> _loadFor(TimetableElementType type) =>
|
||||||
|
_futures.putIfAbsent(type, () => _fetch(type));
|
||||||
|
|
||||||
|
Future<List<_PickerItem>> _loadAll() async {
|
||||||
|
final lists = await Future.wait(TimetableElementType.values.map(_loadFor));
|
||||||
|
return lists.expand((e) => e).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_PickerItem>> _fetch(TimetableElementType type) async {
|
||||||
|
switch (type) {
|
||||||
|
case TimetableElementType.student:
|
||||||
|
final r = await TimetableGetStudents().run();
|
||||||
|
return r.result
|
||||||
|
.map(
|
||||||
|
(s) => _PickerItem(
|
||||||
|
type: type,
|
||||||
|
id: s.id,
|
||||||
|
primary: s.displayName,
|
||||||
|
secondary: '${s.lastName}, ${s.firstName}',
|
||||||
|
search: '${s.displayName} ${s.firstName} ${s.lastName}'
|
||||||
|
.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
case TimetableElementType.teacher:
|
||||||
|
final r = await TimetableGetTeachers().run();
|
||||||
|
return r.result
|
||||||
|
.map(
|
||||||
|
(t) => _PickerItem(
|
||||||
|
type: type,
|
||||||
|
id: t.id,
|
||||||
|
primary: t.displayName,
|
||||||
|
secondary: t.shortName,
|
||||||
|
search: '${t.displayName} ${t.shortName}'.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
case TimetableElementType.room:
|
||||||
|
final r = await TimetableGetRooms().run();
|
||||||
|
return r.result
|
||||||
|
.map(
|
||||||
|
(rm) => _PickerItem(
|
||||||
|
type: type,
|
||||||
|
id: rm.id,
|
||||||
|
primary: rm.shortName,
|
||||||
|
secondary: rm.longName,
|
||||||
|
search: '${rm.shortName} ${rm.longName}'.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
case TimetableElementType.schoolClass:
|
||||||
|
final r = await TimetableGetClasses().run();
|
||||||
|
return r.result
|
||||||
|
.map(
|
||||||
|
(c) => _PickerItem(
|
||||||
|
type: type,
|
||||||
|
id: c.id,
|
||||||
|
primary: c.shortName,
|
||||||
|
secondary: c.longName,
|
||||||
|
search: '${c.shortName} ${c.longName}'.toLowerCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _open(_PickerItem item) {
|
||||||
|
Haptics.selection();
|
||||||
|
// Hand the selection back to the timetable view, which renders the foreign
|
||||||
|
// plan inline. We do not navigate to a new page.
|
||||||
|
Navigator.of(context).pop((
|
||||||
|
type: item.type,
|
||||||
|
id: item.id,
|
||||||
|
label: item.primary,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openFavorite(FavoriteTimetableElement favorite) {
|
||||||
|
Haptics.selection();
|
||||||
|
Navigator.of(context).pop((
|
||||||
|
type: favorite.type,
|
||||||
|
id: favorite.id,
|
||||||
|
label: favorite.label,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavorite(TimetableElementType type, int id, String label) {
|
||||||
|
Haptics.selection();
|
||||||
|
context
|
||||||
|
.read<SettingsCubit>()
|
||||||
|
.val(write: true)
|
||||||
|
.timetableFavoritesSettings
|
||||||
|
.toggle(type, id, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final favorites = context
|
||||||
|
.watch<SettingsCubit>()
|
||||||
|
.val()
|
||||||
|
.timetableFavoritesSettings
|
||||||
|
.favorites;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Stundenplan öffnen')),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (value) => setState(() => _query = value.trim()),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Suchen…',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _query.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _query = '');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 40, child: _typeSelector()),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(child: _list(favorites)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _typeSelector() {
|
||||||
|
return ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: [
|
||||||
|
_typeChip(null),
|
||||||
|
...TimetableElementType.values.map(_typeChip),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _typeChip(TimetableElementType? type) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18),
|
||||||
|
showCheckmark: false,
|
||||||
|
label: Text(type?.label ?? 'Alle'),
|
||||||
|
selected: _selectedType == type,
|
||||||
|
onSelected: (_) {
|
||||||
|
Haptics.selection();
|
||||||
|
setState(() => _selectedType = type);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _list(List<FavoriteTimetableElement> favorites) {
|
||||||
|
return FutureBuilder<List<_PickerItem>>(
|
||||||
|
future: _currentFuture(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: AppProgressIndicator.medium());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ErrorBody(
|
||||||
|
message: errorToUserMessage(snapshot.error),
|
||||||
|
onRetry: () => setState(() {
|
||||||
|
_futures.clear();
|
||||||
|
_allFuture = null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = snapshot.data ?? const <_PickerItem>[];
|
||||||
|
final query = _query.toLowerCase();
|
||||||
|
final filtered = query.isEmpty
|
||||||
|
? items
|
||||||
|
: items.where((i) => i.search.contains(query)).toList();
|
||||||
|
|
||||||
|
// On a specific type tab only that type's favorites are relevant; the
|
||||||
|
// "Alle" tab shows them all.
|
||||||
|
final visibleFavorites = _selectedType == null
|
||||||
|
? favorites
|
||||||
|
: favorites.where((f) => f.type == _selectedType).toList();
|
||||||
|
|
||||||
|
// Favorites only make sense while not actively searching.
|
||||||
|
final showFavorites = _query.isEmpty && visibleFavorites.isNotEmpty;
|
||||||
|
|
||||||
|
if (filtered.isEmpty && !showFavorites) {
|
||||||
|
return const Center(child: Text('Keine Einträge gefunden.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading rows when favorites are shown: a "Favoriten" header, one row
|
||||||
|
// per favorite, then a divider + "Weitere" header that separates them
|
||||||
|
// from the regular results.
|
||||||
|
final headerCount = showFavorites ? visibleFavorites.length + 2 : 0;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: filtered.length + headerCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (showFavorites) {
|
||||||
|
if (index == 0) return _sectionHeader('Favoriten');
|
||||||
|
if (index <= visibleFavorites.length) {
|
||||||
|
return _favoriteTile(visibleFavorites[index - 1]);
|
||||||
|
}
|
||||||
|
if (index == visibleFavorites.length + 1) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
_sectionHeader('Weitere'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final item = filtered[index - headerCount];
|
||||||
|
return _itemTile(item);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(String label) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _favoriteTile(FavoriteTimetableElement favorite) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(_iconFor(favorite.type)),
|
||||||
|
title: Text(favorite.label),
|
||||||
|
subtitle: Text(favorite.type.label),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.star),
|
||||||
|
tooltip: 'Favorit entfernen',
|
||||||
|
onPressed: () =>
|
||||||
|
_toggleFavorite(favorite.type, favorite.id, favorite.label),
|
||||||
|
),
|
||||||
|
onTap: () => _openFavorite(favorite),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _itemTile(_PickerItem item) {
|
||||||
|
final isFavorite = context
|
||||||
|
.watch<SettingsCubit>()
|
||||||
|
.val()
|
||||||
|
.timetableFavoritesSettings
|
||||||
|
.isFavorite(item.type, item.id);
|
||||||
|
|
||||||
|
// In the "Alle" tab the type is otherwise ambiguous, so surface it.
|
||||||
|
final subtitleParts = <String>[
|
||||||
|
if (item.secondary.isNotEmpty && item.secondary != item.primary)
|
||||||
|
item.secondary,
|
||||||
|
if (_selectedType == null) item.type.label,
|
||||||
|
];
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(_iconFor(item.type)),
|
||||||
|
title: Text(item.primary),
|
||||||
|
subtitle: subtitleParts.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(subtitleParts.join(' · ')),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
|
||||||
|
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
|
||||||
|
onPressed: () => _toggleFavorite(item.type, item.id, item.primary),
|
||||||
|
),
|
||||||
|
onTap: () => _open(item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static IconData _iconFor(TimetableElementType type) {
|
||||||
|
switch (type) {
|
||||||
|
case TimetableElementType.student:
|
||||||
|
return Icons.person_outline;
|
||||||
|
case TimetableElementType.teacher:
|
||||||
|
return Icons.school_outlined;
|
||||||
|
case TimetableElementType.room:
|
||||||
|
return Icons.meeting_room_outlined;
|
||||||
|
case TimetableElementType.schoolClass:
|
||||||
|
return Icons.groups_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PickerItem {
|
||||||
|
final TimetableElementType type;
|
||||||
|
final int id;
|
||||||
|
final String primary;
|
||||||
|
final String secondary;
|
||||||
|
final String search;
|
||||||
|
|
||||||
|
_PickerItem({
|
||||||
|
required this.type,
|
||||||
|
required this.id,
|
||||||
|
required this.primary,
|
||||||
|
required this.secondary,
|
||||||
|
required this.search,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBody extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const _ErrorBody({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(message, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: const Text('Erneut versuchen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ class MarianumDateRow extends StatelessWidget {
|
|||||||
final MarianumDate event;
|
final MarianumDate event;
|
||||||
const MarianumDateRow({required this.event, super.key});
|
const MarianumDateRow({required this.event, super.key});
|
||||||
|
|
||||||
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
|
String _dayLabel() => "${event.start.day.toString().padLeft(2, '0')}.";
|
||||||
|
|
||||||
String _monthYearLabel() =>
|
String _monthYearLabel() =>
|
||||||
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../../../storage/modules_settings.dart';
|
|||||||
import '../../../../storage/notification_settings.dart';
|
import '../../../../storage/notification_settings.dart';
|
||||||
import '../../../../storage/settings.dart';
|
import '../../../../storage/settings.dart';
|
||||||
import '../../../../storage/talk_settings.dart';
|
import '../../../../storage/talk_settings.dart';
|
||||||
|
import '../../../../storage/timetable_favorites_settings.dart';
|
||||||
import '../../../../storage/timetable_settings.dart';
|
import '../../../../storage/timetable_settings.dart';
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
import '../../files/data/sort_options.dart';
|
import '../../files/data/sort_options.dart';
|
||||||
@@ -40,6 +41,7 @@ class DefaultSettings {
|
|||||||
connectDoubleLessons: true,
|
connectDoubleLessons: true,
|
||||||
timetableNameMode: TimetableNameMode.name,
|
timetableNameMode: TimetableNameMode.name,
|
||||||
),
|
),
|
||||||
|
timetableFavoritesSettings: TimetableFavoritesSettings(favorites: []),
|
||||||
talkSettings: TalkSettings(
|
talkSettings: TalkSettings(
|
||||||
sortFavoritesToTop: true,
|
sortFavoritesToTop: true,
|
||||||
sortUnreadToTop: false,
|
sortUnreadToTop: false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
|
|||||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../model/account_data.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
|
import '../../../../widget/emoji_text.dart';
|
||||||
import '../../../../widget/loading_spinner.dart';
|
import '../../../../widget/loading_spinner.dart';
|
||||||
import '../../../../widget/placeholder_view.dart';
|
import '../../../../widget/placeholder_view.dart';
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
@@ -59,7 +60,7 @@ class _MessageReactionsState extends State<MessageReactions> {
|
|||||||
collapsedIconColor: Theme.of(context).colorScheme.onSurface,
|
collapsedIconColor: Theme.of(context).colorScheme.onSurface,
|
||||||
|
|
||||||
subtitle: const Text('Tippe für mehr'),
|
subtitle: const Text('Tippe für mehr'),
|
||||||
leading: CenteredLeading(Text(entry.key)),
|
leading: CenteredLeading(EmojiText(entry.key)),
|
||||||
title: Text('${entry.value.length} mal reagiert'),
|
title: Text('${entry.value.length} mal reagiert'),
|
||||||
children: entry.value.map((e) {
|
children: entry.value.map((e) {
|
||||||
final isSelf = AccountData().getUsername() == e.actorId;
|
final isSelf = AccountData().getUsername() == e.actorId;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
|
|||||||
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
|
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
|
import '../../../../widget/emoji_text.dart';
|
||||||
|
|
||||||
/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles
|
/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles
|
||||||
/// the user's own reaction via the Talk API and notifies via [onChanged].
|
/// the user's own reaction via the Talk API and notifies via [onChanged].
|
||||||
@@ -42,7 +43,14 @@ class ChatBubbleReactions extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
|
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
|
||||||
child: ActionChip(
|
child: ActionChip(
|
||||||
label: Text('${e.key} ${e.value}'),
|
label: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
EmojiText(e.key, size: EmojiText.sizeInline),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${e.value}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
visualDensity: const VisualDensity(
|
visualDensity: const VisualDensity(
|
||||||
vertical: VisualDensity.minimumDensity,
|
vertical: VisualDensity.minimumDensity,
|
||||||
horizontal: VisualDensity.minimumDensity,
|
horizontal: VisualDensity.minimumDensity,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
@@ -16,6 +15,8 @@ import '../../../../widget/async_action_button.dart';
|
|||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../../../../widget/emoji_picker_dialog.dart';
|
||||||
|
import '../../../../widget/emoji_text.dart';
|
||||||
import '../data/open_direct_chat.dart';
|
import '../data/open_direct_chat.dart';
|
||||||
|
|
||||||
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
||||||
@@ -222,7 +223,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
|||||||
minimumSize: const Size(40, 40),
|
minimumSize: const Size(40, 40),
|
||||||
),
|
),
|
||||||
onPressed: busy ? null : () => _react(emoji),
|
onPressed: busy ? null : () => _react(emoji),
|
||||||
child: Text(emoji),
|
child: EmojiText(emoji, size: EmojiText.sizeLarge),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -256,56 +257,8 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
void _showEmojiPicker(BuildContext rowContext) {
|
Future<void> _showEmojiPicker(BuildContext rowContext) async {
|
||||||
showDialog(
|
final emoji = await showEmojiPicker(rowContext, title: 'Reagieren');
|
||||||
context: rowContext,
|
if (emoji != null && mounted) await _react(emoji);
|
||||||
builder: (pickerCtx) => AlertDialog(
|
|
||||||
contentPadding: const EdgeInsets.all(15),
|
|
||||||
titlePadding: const EdgeInsets.only(left: 6, top: 15),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(pickerCtx).pop(),
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text('Reagieren'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 256,
|
|
||||||
height: 270,
|
|
||||||
child: emojis.EmojiPicker(
|
|
||||||
config: emojis.Config(
|
|
||||||
height: 256,
|
|
||||||
emojiViewConfig: emojis.EmojiViewConfig(
|
|
||||||
backgroundColor: Theme.of(pickerCtx).canvasColor,
|
|
||||||
recentsLimit: 67,
|
|
||||||
emojiSizeMax: 25,
|
|
||||||
noRecents: const Text('Keine zuletzt verwendeten Emojis'),
|
|
||||||
columns: 7,
|
|
||||||
),
|
|
||||||
bottomActionBarConfig: const emojis.BottomActionBarConfig(
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
categoryViewConfig: emojis.CategoryViewConfig(
|
|
||||||
backgroundColor: Theme.of(pickerCtx).hoverColor,
|
|
||||||
iconColorSelected: Theme.of(pickerCtx).primaryColor,
|
|
||||||
indicatorColor: Theme.of(pickerCtx).primaryColor,
|
|
||||||
),
|
|
||||||
searchViewConfig: emojis.SearchViewConfig(
|
|
||||||
backgroundColor: Theme.of(pickerCtx).dividerColor,
|
|
||||||
hintText: 'Suchen',
|
|
||||||
buttonIconColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onEmojiSelected: (_, emoji) {
|
|
||||||
Navigator.of(pickerCtx).pop();
|
|
||||||
_react(emoji.emoji);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
|||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
import '../../../../widget/emoji_picker_dialog.dart';
|
||||||
import '../../../../widget/file_pick.dart';
|
import '../../../../widget/file_pick.dart';
|
||||||
import '../../../../widget/focus_behaviour.dart';
|
import '../../../../widget/focus_behaviour.dart';
|
||||||
import '../../files/files_upload_dialog.dart';
|
import '../../files/files_upload_dialog.dart';
|
||||||
@@ -32,6 +33,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
late SettingsCubit settings;
|
late SettingsCubit settings;
|
||||||
final TextEditingController _textBoxController = TextEditingController();
|
final TextEditingController _textBoxController = TextEditingController();
|
||||||
final AsyncActionController _sendController = AsyncActionController();
|
final AsyncActionController _sendController = AsyncActionController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
String? _sendError;
|
String? _sendError;
|
||||||
|
|
||||||
void share(List<String> uploadedRemotePaths) {
|
void share(List<String> uploadedRemotePaths) {
|
||||||
@@ -103,9 +105,71 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_sendController.dispose();
|
_sendController.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showAttachmentSheet() {
|
||||||
|
showDetailsBottomSheet(
|
||||||
|
context,
|
||||||
|
children: (sheetCtx) => [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.file_open),
|
||||||
|
title: const Text('Aus Dateien auswählen'),
|
||||||
|
onTap: () {
|
||||||
|
FilePick.documentPick().then(mediaUpload);
|
||||||
|
Navigator.of(sheetCtx).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.image),
|
||||||
|
title: const Text('Aus Galerie auswählen'),
|
||||||
|
onTap: () {
|
||||||
|
FilePick.multipleGalleryPick().then((value) {
|
||||||
|
if (value != null) {
|
||||||
|
mediaUpload(value.map((e) => e.path).toList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.of(sheetCtx).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt_outlined),
|
||||||
|
title: const Text('Foto aufnehmen'),
|
||||||
|
onTap: () {
|
||||||
|
FilePick.cameraPick().then((image) {
|
||||||
|
if (image != null) mediaUpload([image.path]);
|
||||||
|
});
|
||||||
|
Navigator.of(sheetCtx).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickEmoji() async {
|
||||||
|
final emoji = await showEmojiPicker(context);
|
||||||
|
if (emoji == null || !mounted) return;
|
||||||
|
_insertEmoji(emoji);
|
||||||
|
// Keep the field focused so the user can keep typing after inserting.
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertEmoji(String emoji) {
|
||||||
|
final selection = _textBoxController.selection;
|
||||||
|
final text = _textBoxController.text;
|
||||||
|
// Selection is invalid (-1) until the field was focused once — append then.
|
||||||
|
final start = selection.start < 0 ? text.length : selection.start;
|
||||||
|
final end = selection.end < 0 ? text.length : selection.end;
|
||||||
|
final newText = text.replaceRange(start, end, emoji);
|
||||||
|
_textBoxController.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(offset: start + emoji.length),
|
||||||
|
);
|
||||||
|
// Programmatic edits skip the TextField's onChanged, so persist manually.
|
||||||
|
_setDraft(newText);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage(ChatBloc chatBloc) async {
|
Future<void> _sendMessage(ChatBloc chatBloc) async {
|
||||||
if (_textBoxController.text.isEmpty) return;
|
if (_textBoxController.text.isEmpty) return;
|
||||||
final text = _textBoxController.text;
|
final text = _textBoxController.text;
|
||||||
@@ -199,78 +263,55 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
|
// Outer row centers the pill against the (taller) send FAB.
|
||||||
|
// The inner row keeps end-alignment so the icon buttons drop
|
||||||
|
// to the bottom once the text wraps to multiple lines.
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
showDetailsBottomSheet(
|
|
||||||
context,
|
|
||||||
children: (sheetCtx) => [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.file_open),
|
|
||||||
title: const Text('Aus Dateien auswählen'),
|
|
||||||
onTap: () {
|
|
||||||
FilePick.documentPick().then(mediaUpload);
|
|
||||||
Navigator.of(sheetCtx).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.image),
|
|
||||||
title: const Text('Aus Galerie auswählen'),
|
|
||||||
onTap: () {
|
|
||||||
FilePick.multipleGalleryPick().then((value) {
|
|
||||||
if (value != null) {
|
|
||||||
mediaUpload(
|
|
||||||
value.map((e) => e.path).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Navigator.of(sheetCtx).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.camera_alt_outlined),
|
|
||||||
title: const Text('Foto aufnehmen'),
|
|
||||||
onTap: () {
|
|
||||||
FilePick.cameraPick().then((image) {
|
|
||||||
if (image != null) mediaUpload([image.path]);
|
|
||||||
});
|
|
||||||
Navigator.of(sheetCtx).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Material(
|
|
||||||
elevation: 5,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
height: 30,
|
|
||||||
width: 30,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.attach_file_outlined,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 15),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
tooltip: 'Emoji einfügen',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.emoji_emotions_outlined,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
onPressed: _pickEmoji,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
// 7px keeps a single line as tall as the
|
||||||
|
// 32px icon buttons, so end-alignment reads as
|
||||||
|
// centered for one line but drops the buttons
|
||||||
|
// to the bottom once the text wraps.
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 7,
|
||||||
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
autocorrect: true,
|
autocorrect: true,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization:
|
||||||
|
TextCapitalization.sentences,
|
||||||
controller: _textBoxController,
|
controller: _textBoxController,
|
||||||
|
focusNode: _focusNode,
|
||||||
maxLines: 7,
|
maxLines: 7,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Nachricht schreiben...',
|
hintText: 'Nachricht schreiben...',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
),
|
),
|
||||||
onChanged: (text) {
|
onChanged: (text) {
|
||||||
if (text.trim().toLowerCase() ==
|
if (text.trim().toLowerCase() ==
|
||||||
@@ -283,10 +324,28 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
_setDraft(text);
|
_setDraft(text);
|
||||||
},
|
},
|
||||||
onTapOutside: (_) =>
|
onTapOutside: (_) =>
|
||||||
FocusBehaviour.textFieldTapOutside(context),
|
FocusBehaviour.textFieldTapOutside(
|
||||||
|
context,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 15),
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
tooltip: 'Anhang',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.attach_file_outlined,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
onPressed: _showAttachmentSheet,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
ValueListenableBuilder<TextEditingValue>(
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
valueListenable: _textBoxController,
|
valueListenable: _textBoxController,
|
||||||
builder: (context, value, _) => AsyncFab(
|
builder: (context, value, _) => AsyncFab(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
import 'custom_event_sheet.dart';
|
import 'custom_event_sheet.dart';
|
||||||
import 'lesson_sheet.dart';
|
import 'lesson_sheet.dart';
|
||||||
@@ -9,15 +9,14 @@ import 'lesson_sheet.dart';
|
|||||||
class AppointmentDetailsDispatcher {
|
class AppointmentDetailsDispatcher {
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TimetableBloc bloc,
|
TimetableState? state,
|
||||||
Appointment appointment,
|
Appointment appointment,
|
||||||
) {
|
) {
|
||||||
final id = appointment.id;
|
final id = appointment.id;
|
||||||
if (id is! ArbitraryAppointment) return;
|
if (id is! ArbitraryAppointment) return;
|
||||||
|
|
||||||
id.when(
|
id.when(
|
||||||
lesson: (entry) =>
|
lesson: (entry) => LessonSheet.show(context, state, appointment, entry),
|
||||||
LessonSheet.show(context, bloc, appointment, entry),
|
|
||||||
custom: (event) => CustomEventSheet.show(context, event),
|
custom: (event) => CustomEventSheet.show(context, event),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get
|
|||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../data/lesson_type_label.dart';
|
import '../data/lesson_type_label.dart';
|
||||||
@@ -13,11 +13,10 @@ import '../data/lesson_type_label.dart';
|
|||||||
class LessonSheet {
|
class LessonSheet {
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TimetableBloc bloc,
|
TimetableState? state,
|
||||||
Appointment appointment,
|
Appointment appointment,
|
||||||
McTimetableEntry lesson,
|
McTimetableEntry lesson,
|
||||||
) {
|
) {
|
||||||
final state = bloc.state.data;
|
|
||||||
if (state == null) return;
|
if (state == null) return;
|
||||||
|
|
||||||
final subjectShort = lesson.subjects.firstOrNull;
|
final subjectShort = lesson.subjects.firstOrNull;
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|
||||||
|
|
||||||
|
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../../../extensions/date_time.dart';
|
import '../../../extensions/date_time.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
|
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
|
import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../storage/timetable_settings.dart';
|
import '../../../utils/haptics.dart';
|
||||||
import 'custom_events/custom_event_edit_dialog.dart';
|
import 'custom_events/custom_event_edit_dialog.dart';
|
||||||
import 'data/arbitrary_appointment.dart';
|
|
||||||
import 'data/lesson_period_schedule.dart';
|
|
||||||
import 'data/timetable_appointment_factory.dart';
|
|
||||||
import 'details/appointment_details_dispatcher.dart';
|
import 'details/appointment_details_dispatcher.dart';
|
||||||
import 'widgets/custom_workweek_calendar.dart';
|
import 'widgets/timetable_calendar_view.dart';
|
||||||
import 'widgets/special_regions_builder.dart';
|
|
||||||
|
|
||||||
enum _CalendarAction { addEvent, viewEvents }
|
enum _CalendarAction { addEvent, viewEvents }
|
||||||
|
|
||||||
@@ -27,17 +25,30 @@ class Timetable extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TimetableState extends State<Timetable> {
|
class _TimetableState extends State<Timetable> {
|
||||||
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
final GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||||
GlobalKey<CustomWorkWeekCalendarState>();
|
GlobalKey<TimetableCalendarViewState>();
|
||||||
|
|
||||||
List<Appointment>? _cachedAppointments;
|
/// When non-null the view shows this element's plan inline instead of the
|
||||||
int? _lastDataVersion;
|
/// user's own. Cleared (back to own plan) via the viewing banner.
|
||||||
TimetableSettings? _lastTimetableSettings;
|
TimetableElementRef? _selected;
|
||||||
|
|
||||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||||
|
|
||||||
void _jumpToToday() {
|
void _jumpToToday() {
|
||||||
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
_calendarKey.currentState?.jumpToToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isOnInitialWeek(TimetableState state) =>
|
||||||
|
state.startDate == _mondayOf(_initialDisplayDate());
|
||||||
|
|
||||||
|
Future<void> _openPicker() async {
|
||||||
|
final ref = await AppRoutes.openElementPicker(context);
|
||||||
|
if (!mounted || ref == null) return;
|
||||||
|
setState(() => _selected = ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _backToOwnPlan() {
|
||||||
|
setState(() => _selected = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAction(_CalendarAction action) {
|
void _onAction(_CalendarAction action) {
|
||||||
@@ -53,43 +64,44 @@ class _TimetableState extends State<Timetable> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Appointment> _appointments(TimetableState state) {
|
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||||
final timetableSettings = context
|
showDialog(
|
||||||
.watch<SettingsCubit>()
|
context: context,
|
||||||
.val()
|
builder: (_) =>
|
||||||
.timetableSettings;
|
CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||||
if (_cachedAppointments != null &&
|
barrierDismissible: false,
|
||||||
_lastDataVersion == state.dataVersion &&
|
);
|
||||||
identical(_lastTimetableSettings, timetableSettings)) {
|
|
||||||
return _cachedAppointments!;
|
|
||||||
}
|
}
|
||||||
_lastDataVersion = state.dataVersion;
|
|
||||||
_lastTimetableSettings = timetableSettings;
|
|
||||||
|
|
||||||
return _cachedAppointments = TimetableAppointmentFactory(
|
|
||||||
lessons: state.getAllKnownLessons().toList(),
|
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
|
||||||
subjects: state.subjects?.result ?? const [],
|
|
||||||
settings: timetableSettings,
|
|
||||||
now: DateTime.now(),
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isCrossedOut(Appointment appointment) {
|
|
||||||
final id = appointment.id;
|
|
||||||
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isOnInitialWeek(TimetableState state) =>
|
|
||||||
state.startDate == _mondayOf(_initialDisplayDate());
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selected = _selected;
|
||||||
|
if (selected == null) return _buildOwnPlan(context);
|
||||||
|
// Scope the foreign bloc to the current selection so switching elements
|
||||||
|
// (or back to the own plan) tears it down and builds a fresh one.
|
||||||
|
return BlocProvider<ForeignTimetableBloc>(
|
||||||
|
key: ValueKey('${selected.type.name}-${selected.id}'),
|
||||||
|
create: (_) => ForeignTimetableBloc(
|
||||||
|
type: selected.type,
|
||||||
|
elementId: selected.id,
|
||||||
|
title: selected.label,
|
||||||
|
),
|
||||||
|
// Builder gives us a context *below* the provider so the foreign bloc is
|
||||||
|
// resolvable inside _buildForeignPlan.
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => _buildForeignPlan(context, selected),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOwnPlan(BuildContext context) {
|
||||||
final bloc = context.read<TimetableBloc>();
|
final bloc = context.read<TimetableBloc>();
|
||||||
final loadableState = context.watch<TimetableBloc>().state;
|
final loadableState = context.watch<TimetableBloc>().state;
|
||||||
final innerState = loadableState.data;
|
final innerState = loadableState.data;
|
||||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||||
|
final canViewForeign = context
|
||||||
|
.watch<CapabilitiesCubit>()
|
||||||
|
.canViewForeignTimetables;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Stunden & Vertretungsplan'),
|
title: const Text('Stunden & Vertretungsplan'),
|
||||||
@@ -118,125 +130,178 @@ class _TimetableState extends State<Timetable> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (canViewForeign)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.person_search),
|
||||||
|
tooltip: 'Anderen Stundenplan öffnen',
|
||||||
|
onPressed: _openPicker,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||||
// Without this predicate the consumer treats the freshly-initialised
|
// Without this predicate the consumer treats the freshly-initialised
|
||||||
// empty TimetableState as "has content" and only shows the error bar
|
// empty TimetableState as "has content" and only shows the error bar
|
||||||
// on top — but `_calendar` collapses to `SizedBox.shrink()` while the
|
// on top — but the calendar view collapses to `SizedBox.shrink()`
|
||||||
// reference data is missing, leaving the user with a blank screen.
|
// while the reference data is missing, leaving the user with a blank
|
||||||
// Telling the consumer that "ready" means having reference data
|
// screen. Telling the consumer that "ready" means having reference
|
||||||
// flips it into the proper error-screen path instead.
|
// data flips it into the proper error-screen path instead.
|
||||||
isReady: (state) => state.hasReferenceData,
|
isReady: (state) => state.hasReferenceData,
|
||||||
child: (state, _) => _calendar(state, bloc),
|
child: (state, _) => TimetableCalendarView(
|
||||||
|
key: _calendarKey,
|
||||||
|
state: state,
|
||||||
|
onWeekChanged: bloc.changeWeek,
|
||||||
|
onAppointmentTap: (apt) =>
|
||||||
|
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||||
|
onCreateEvent: _onCreateEventAt,
|
||||||
|
customEvents: state.customEvents?.events ?? const [],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _calendar(TimetableState state, TimetableBloc bloc) {
|
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
|
||||||
if (!state.hasReferenceData) return const SizedBox.shrink();
|
final bloc = context.read<ForeignTimetableBloc>();
|
||||||
|
final loadableState = context.watch<ForeignTimetableBloc>().state;
|
||||||
final schedule = LessonPeriodSchedule.fromState(state);
|
final innerState = loadableState.data;
|
||||||
final appointments = _appointments(state);
|
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||||
final regions = SpecialRegionsBuilder(
|
final canViewForeign = context
|
||||||
holidays: state.schoolHolidays!,
|
.watch<CapabilitiesCubit>()
|
||||||
schedule: schedule,
|
.canViewForeignTimetables;
|
||||||
colorScheme: Theme.of(context).colorScheme,
|
return Scaffold(
|
||||||
disabledColor: Theme.of(context).disabledColor,
|
appBar: AppBar(
|
||||||
).build();
|
title: const Text('Stunden & Vertretungsplan'),
|
||||||
|
actions: [
|
||||||
// Scroll bounds follow the Webuntis school-year API: the calendar lets
|
IconButton(
|
||||||
// the user navigate every week the server has data for. A two-week
|
icon: const Icon(Icons.home_outlined),
|
||||||
// fallback is used only while the school-year payload hasn't loaded yet
|
onPressed: atToday ? null : _jumpToToday,
|
||||||
// (first launch / offline), so the calendar still mounts.
|
),
|
||||||
final (minDate, maxDate) = _scrollBounds(state);
|
if (canViewForeign)
|
||||||
|
IconButton(
|
||||||
return CustomWorkWeekCalendar(
|
icon: const Icon(Icons.person_search),
|
||||||
|
tooltip: 'Anderen Stundenplan öffnen',
|
||||||
|
onPressed: _openPicker,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
|
||||||
|
Expanded(
|
||||||
|
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
|
||||||
|
// Foreign plans never carry custom events, so unlike the own-plan
|
||||||
|
// view we must not require `customEvents` here.
|
||||||
|
isReady: (state) =>
|
||||||
|
state.rooms != null &&
|
||||||
|
state.subjects != null &&
|
||||||
|
state.schoolHolidays != null,
|
||||||
|
child: (state, _) => TimetableCalendarView(
|
||||||
key: _calendarKey,
|
key: _calendarKey,
|
||||||
schedule: schedule,
|
state: state,
|
||||||
appointments: appointments,
|
onWeekChanged: bloc.changeWeek,
|
||||||
timeRegions: regions,
|
|
||||||
initialDate: _initialDisplayDate(),
|
|
||||||
minDate: minDate,
|
|
||||||
maxDate: maxDate,
|
|
||||||
onAppointmentTap: (apt) =>
|
onAppointmentTap: (apt) =>
|
||||||
AppointmentDetailsDispatcher.show(context, bloc, apt),
|
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||||
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
|
customEvents: const [],
|
||||||
isCrossedOut: _isCrossedOut,
|
),
|
||||||
onCreateEvent: _onCreateEventAt,
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCreateEventAt(DateTime start, DateTime end) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) =>
|
|
||||||
CustomEventEditDialog(initialStart: start, initialEnd: end),
|
|
||||||
barrierDismissible: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
|
||||||
/// school year (or a stale persisted bound) would let the user scroll
|
|
||||||
/// further, we never expose more than this much around the current week —
|
|
||||||
/// containment for any future date-math bug that might otherwise teleport
|
|
||||||
/// the user months away from today.
|
|
||||||
static const int _maxWeeksBack = 4;
|
|
||||||
static const int _maxWeeksForward = 2;
|
|
||||||
|
|
||||||
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
|
||||||
/// Starts from the Webuntis school year (or a tight window when that
|
|
||||||
/// hasn't loaded yet), tightens by anything the bloc has learned from
|
|
||||||
/// `-7004 no allowed date` errors during scroll — so the user can't
|
|
||||||
/// slide off into territory Webuntis would refuse anyway — and finally
|
|
||||||
/// clamps to a fixed window around today.
|
|
||||||
///
|
|
||||||
/// minDate is snapped *forward* to the next Monday because the calendar's
|
|
||||||
/// internal `_mondayOf()` would otherwise pull a mid-week minDate back
|
|
||||||
/// into the just-rejected week. maxDate is passed through unsnapped —
|
|
||||||
/// `_mondayOf()` correctly walks back to the Monday of its own week,
|
|
||||||
/// which is the last fully-allowed week.
|
|
||||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
|
||||||
final year = state.schoolyear;
|
|
||||||
final DateTime baseMin;
|
|
||||||
final DateTime baseMax;
|
|
||||||
if (year != null) {
|
|
||||||
baseMin = year.startDate;
|
|
||||||
baseMax = year.endDate;
|
|
||||||
} else {
|
|
||||||
final now = DateTime.now();
|
|
||||||
baseMin = now.subtractDays(14);
|
|
||||||
baseMax = now.addDays(7);
|
|
||||||
}
|
|
||||||
final effectiveMin = state.accessibleStartDate != null
|
|
||||||
? (state.accessibleStartDate!.isAfter(baseMin)
|
|
||||||
? state.accessibleStartDate!
|
|
||||||
: baseMin)
|
|
||||||
: baseMin;
|
|
||||||
final effectiveMax = state.accessibleEndDate != null
|
|
||||||
? (state.accessibleEndDate!.isBefore(baseMax)
|
|
||||||
? state.accessibleEndDate!
|
|
||||||
: baseMax)
|
|
||||||
: baseMax;
|
|
||||||
final todayMonday = _mondayOf(DateTime.now());
|
|
||||||
final cappedMin = effectiveMin.isBefore(
|
|
||||||
todayMonday.subtractDays(_maxWeeksBack * 7),
|
|
||||||
)
|
|
||||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
|
||||||
: effectiveMin;
|
|
||||||
final cappedMax = effectiveMax.isAfter(
|
|
||||||
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
|
||||||
)
|
|
||||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
|
||||||
: effectiveMax;
|
|
||||||
final daysToMonday =
|
|
||||||
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
|
|
||||||
final mondayMin = cappedMin.addDays(daysToMonday);
|
|
||||||
return (mondayMin, cappedMax);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime _mondayOf(DateTime d) {
|
static DateTime _mondayOf(DateTime d) {
|
||||||
final monday = d.subtractDays(d.weekday - 1);
|
final monday = d.subtractDays(d.weekday - 1);
|
||||||
return DateTime(monday.year, monday.month, monday.day);
|
return DateTime(monday.year, monday.month, monday.day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||||
|
/// is being viewed. Displays which element is shown, lets the user star it, and
|
||||||
|
/// offers a one-tap return to the own plan.
|
||||||
|
class _ViewingBanner extends StatelessWidget {
|
||||||
|
final TimetableElementRef element;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const _ViewingBanner({required this.element, required this.onClose});
|
||||||
|
|
||||||
|
void _toggleFavorite(BuildContext context) {
|
||||||
|
Haptics.selection();
|
||||||
|
context
|
||||||
|
.read<SettingsCubit>()
|
||||||
|
.val(write: true)
|
||||||
|
.timetableFavoritesSettings
|
||||||
|
.toggle(element.type, element.id, element.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isFavorite = context
|
||||||
|
.watch<SettingsCubit>()
|
||||||
|
.val()
|
||||||
|
.timetableFavoritesSettings
|
||||||
|
.isFavorite(element.type, element.id);
|
||||||
|
|
||||||
|
final onColor = theme.colorScheme.onSecondaryContainer;
|
||||||
|
// Compact icon button: ~32px square, no extra padding, so the banner stays
|
||||||
|
// slim instead of inheriting the default 48px touch target height.
|
||||||
|
Widget compactButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String tooltip,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) => IconButton(
|
||||||
|
icon: Icon(icon),
|
||||||
|
iconSize: 18,
|
||||||
|
color: onColor,
|
||||||
|
tooltip: tooltip,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 2, 6, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(_iconFor(element.type), size: 16, color: onColor),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
element.label,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(color: onColor),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
compactButton(
|
||||||
|
icon: isFavorite ? Icons.star : Icons.star_border,
|
||||||
|
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
|
||||||
|
onPressed: () => _toggleFavorite(context),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
compactButton(
|
||||||
|
icon: Icons.close,
|
||||||
|
tooltip: 'Zurück zum eigenen Plan',
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static IconData _iconFor(TimetableElementType type) {
|
||||||
|
switch (type) {
|
||||||
|
case TimetableElementType.student:
|
||||||
|
return Icons.person_outline;
|
||||||
|
case TimetableElementType.teacher:
|
||||||
|
return Icons.school_outlined;
|
||||||
|
case TimetableElementType.room:
|
||||||
|
return Icons.meeting_room_outlined;
|
||||||
|
case TimetableElementType.schoolClass:
|
||||||
|
return Icons.groups_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
|
import '../../../../storage/timetable_settings.dart';
|
||||||
|
import '../data/arbitrary_appointment.dart';
|
||||||
|
import '../data/lesson_period_schedule.dart';
|
||||||
|
import '../data/timetable_appointment_factory.dart';
|
||||||
|
import 'custom_workweek_calendar.dart';
|
||||||
|
import 'special_regions_builder.dart';
|
||||||
|
|
||||||
|
/// Renders a weekly timetable from a [TimetableState]. Shared by the user's own
|
||||||
|
/// plan and the foreign-element view; the only differences are which custom
|
||||||
|
/// events to overlay (none for foreign plans) and whether tapping an empty slot
|
||||||
|
/// can create an event ([onCreateEvent] is null for read-only foreign plans).
|
||||||
|
///
|
||||||
|
/// The week navigation and appointment-tap callbacks are supplied by the host
|
||||||
|
/// page so each can route them to its own bloc.
|
||||||
|
class TimetableCalendarView extends StatefulWidget {
|
||||||
|
final TimetableState state;
|
||||||
|
final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged;
|
||||||
|
final void Function(Appointment appointment) onAppointmentTap;
|
||||||
|
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||||
|
final List<CustomTimetableEvent> customEvents;
|
||||||
|
|
||||||
|
const TimetableCalendarView({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.onWeekChanged,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
this.onCreateEvent,
|
||||||
|
this.customEvents = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TimetableCalendarView> createState() => TimetableCalendarViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||||
|
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
||||||
|
GlobalKey<CustomWorkWeekCalendarState>();
|
||||||
|
|
||||||
|
List<Appointment>? _cachedAppointments;
|
||||||
|
int? _lastDataVersion;
|
||||||
|
TimetableSettings? _lastTimetableSettings;
|
||||||
|
List<CustomTimetableEvent>? _lastCustomEvents;
|
||||||
|
|
||||||
|
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||||
|
|
||||||
|
/// Snaps the calendar back to the current week. Exposed so host pages can
|
||||||
|
/// wire it to a "today" AppBar action.
|
||||||
|
void jumpToToday() {
|
||||||
|
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isOnInitialWeek() =>
|
||||||
|
widget.state.startDate == _mondayOf(_initialDisplayDate());
|
||||||
|
|
||||||
|
List<Appointment> _appointments(TimetableState state) {
|
||||||
|
final timetableSettings = context
|
||||||
|
.watch<SettingsCubit>()
|
||||||
|
.val()
|
||||||
|
.timetableSettings;
|
||||||
|
if (_cachedAppointments != null &&
|
||||||
|
_lastDataVersion == state.dataVersion &&
|
||||||
|
identical(_lastTimetableSettings, timetableSettings) &&
|
||||||
|
identical(_lastCustomEvents, widget.customEvents)) {
|
||||||
|
return _cachedAppointments!;
|
||||||
|
}
|
||||||
|
_lastDataVersion = state.dataVersion;
|
||||||
|
_lastTimetableSettings = timetableSettings;
|
||||||
|
_lastCustomEvents = widget.customEvents;
|
||||||
|
|
||||||
|
return _cachedAppointments = TimetableAppointmentFactory(
|
||||||
|
lessons: state.getAllKnownLessons().toList(),
|
||||||
|
customEvents: widget.customEvents,
|
||||||
|
subjects: state.subjects?.result ?? const [],
|
||||||
|
settings: timetableSettings,
|
||||||
|
now: DateTime.now(),
|
||||||
|
).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCrossedOut(Appointment appointment) {
|
||||||
|
final id = appointment.id;
|
||||||
|
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = widget.state;
|
||||||
|
// Reference data is gated by the host's LoadableStateConsumer isReady
|
||||||
|
// predicate, but guard the one hard dereference (schoolHolidays) so a
|
||||||
|
// transient null can never crash the build.
|
||||||
|
if (state.schoolHolidays == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final schedule = LessonPeriodSchedule.fromState(state);
|
||||||
|
final appointments = _appointments(state);
|
||||||
|
final regions = SpecialRegionsBuilder(
|
||||||
|
holidays: state.schoolHolidays!,
|
||||||
|
schedule: schedule,
|
||||||
|
colorScheme: Theme.of(context).colorScheme,
|
||||||
|
disabledColor: Theme.of(context).disabledColor,
|
||||||
|
).build();
|
||||||
|
|
||||||
|
final (minDate, maxDate) = _scrollBounds(state);
|
||||||
|
|
||||||
|
return CustomWorkWeekCalendar(
|
||||||
|
key: _calendarKey,
|
||||||
|
schedule: schedule,
|
||||||
|
appointments: appointments,
|
||||||
|
timeRegions: regions,
|
||||||
|
initialDate: _initialDisplayDate(),
|
||||||
|
minDate: minDate,
|
||||||
|
maxDate: maxDate,
|
||||||
|
onAppointmentTap: widget.onAppointmentTap,
|
||||||
|
onWeekChanged: widget.onWeekChanged,
|
||||||
|
isCrossedOut: _isCrossedOut,
|
||||||
|
onCreateEvent: widget.onCreateEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
||||||
|
/// school year (or a stale persisted bound) would let the user scroll
|
||||||
|
/// further, we never expose more than this much around the current week.
|
||||||
|
static const int _maxWeeksBack = 4;
|
||||||
|
static const int _maxWeeksForward = 2;
|
||||||
|
|
||||||
|
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
||||||
|
/// Starts from the Webuntis school year (or a tight window when that hasn't
|
||||||
|
/// loaded yet), tightens by anything the bloc has learned from past denials,
|
||||||
|
/// and finally clamps to a fixed window around today.
|
||||||
|
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||||
|
final year = state.schoolyear;
|
||||||
|
final DateTime baseMin;
|
||||||
|
final DateTime baseMax;
|
||||||
|
if (year != null) {
|
||||||
|
baseMin = year.startDate;
|
||||||
|
baseMax = year.endDate;
|
||||||
|
} else {
|
||||||
|
final now = DateTime.now();
|
||||||
|
baseMin = now.subtractDays(14);
|
||||||
|
baseMax = now.addDays(7);
|
||||||
|
}
|
||||||
|
final effectiveMin = state.accessibleStartDate != null
|
||||||
|
? (state.accessibleStartDate!.isAfter(baseMin)
|
||||||
|
? state.accessibleStartDate!
|
||||||
|
: baseMin)
|
||||||
|
: baseMin;
|
||||||
|
final effectiveMax = state.accessibleEndDate != null
|
||||||
|
? (state.accessibleEndDate!.isBefore(baseMax)
|
||||||
|
? state.accessibleEndDate!
|
||||||
|
: baseMax)
|
||||||
|
: baseMax;
|
||||||
|
final todayMonday = _mondayOf(DateTime.now());
|
||||||
|
final cappedMin = effectiveMin.isBefore(
|
||||||
|
todayMonday.subtractDays(_maxWeeksBack * 7),
|
||||||
|
)
|
||||||
|
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||||
|
: effectiveMin;
|
||||||
|
final cappedMax = effectiveMax.isAfter(
|
||||||
|
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
||||||
|
)
|
||||||
|
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||||
|
: effectiveMax;
|
||||||
|
final daysToMonday =
|
||||||
|
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
|
||||||
|
final mondayMin = cappedMin.addDays(daysToMonday);
|
||||||
|
return (mondayMin, cappedMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _mondayOf(DateTime d) {
|
||||||
|
final monday = d.subtractDays(d.weekday - 1);
|
||||||
|
return DateTime(monday.year, monday.month, monday.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'emoji_text.dart';
|
||||||
|
|
||||||
|
/// Shows the app-wide emoji picker and resolves with the chosen emoji, or
|
||||||
|
/// `null` if the dialog was dismissed without a selection.
|
||||||
|
///
|
||||||
|
/// Single source of truth for the picker styling so every entry point (message
|
||||||
|
/// reactions, the compose field, …) looks and behaves the same.
|
||||||
|
Future<String?> showEmojiPicker(
|
||||||
|
BuildContext context, {
|
||||||
|
String title = 'Emoji wählen',
|
||||||
|
}) {
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (pickerCtx) => AlertDialog(
|
||||||
|
contentPadding: const EdgeInsets.all(15),
|
||||||
|
titlePadding: const EdgeInsets.only(left: 6, top: 15),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(pickerCtx).pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
height: 360,
|
||||||
|
child: emojis.EmojiPicker(
|
||||||
|
config: emojis.Config(
|
||||||
|
height: 360,
|
||||||
|
emojiViewConfig: emojis.EmojiViewConfig(
|
||||||
|
backgroundColor: Theme.of(pickerCtx).canvasColor,
|
||||||
|
recentsLimit: 67,
|
||||||
|
emojiSizeMax: EmojiText.sizeLarge,
|
||||||
|
noRecents: const Text('Keine zuletzt verwendeten Emojis'),
|
||||||
|
columns: 7,
|
||||||
|
),
|
||||||
|
bottomActionBarConfig: const emojis.BottomActionBarConfig(
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
categoryViewConfig: emojis.CategoryViewConfig(
|
||||||
|
backgroundColor: Theme.of(pickerCtx).hoverColor,
|
||||||
|
iconColorSelected: Theme.of(pickerCtx).primaryColor,
|
||||||
|
indicatorColor: Theme.of(pickerCtx).primaryColor,
|
||||||
|
),
|
||||||
|
searchViewConfig: emojis.SearchViewConfig(
|
||||||
|
backgroundColor: Theme.of(pickerCtx).dividerColor,
|
||||||
|
hintText: 'Suchen',
|
||||||
|
buttonIconColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onEmojiSelected: (_, emoji) {
|
||||||
|
Navigator.of(pickerCtx).pop(emoji.emoji);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Central place for rendering emoji glyphs (reactions, pickers, …).
|
||||||
|
///
|
||||||
|
/// Emojis used to be drawn as bare `Text(emoji)` widgets all over the Talk UI,
|
||||||
|
/// which meant they inherited the small default body text size and looked
|
||||||
|
/// inconsistent. [EmojiText] gives every emoji a uniform, comfortably large
|
||||||
|
/// size and forces the platform's color-emoji font so the rendering is the same
|
||||||
|
/// everywhere.
|
||||||
|
class EmojiText extends StatelessWidget {
|
||||||
|
/// Size for emojis shown inline next to other text, e.g. reaction chips.
|
||||||
|
static const double sizeInline = 15;
|
||||||
|
|
||||||
|
/// Default size for standalone emojis, e.g. list leadings.
|
||||||
|
static const double sizeStandard = 20;
|
||||||
|
|
||||||
|
/// Size for primary tap targets, e.g. the quick-reaction buttons.
|
||||||
|
static const double sizeLarge = 24;
|
||||||
|
|
||||||
|
final String emoji;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const EmojiText(this.emoji, {this.size = sizeStandard, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Text(
|
||||||
|
emoji,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: size,
|
||||||
|
height: 1.0,
|
||||||
|
// Render emojis with the platform color-emoji font instead of the app
|
||||||
|
// font, so they look identical across all usages and devices.
|
||||||
|
fontFamilyFallback: const ['Noto Color Emoji', 'Apple Color Emoji'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('TimetableElementType.pathSegment', () {
|
||||||
|
test('maps each type to the backend URL segment', () {
|
||||||
|
expect(TimetableElementType.student.pathSegment, 'student');
|
||||||
|
expect(TimetableElementType.teacher.pathSegment, 'teacher');
|
||||||
|
expect(TimetableElementType.room.pathSegment, 'room');
|
||||||
|
// `schoolClass` exists only because `class` is a reserved word; the
|
||||||
|
// backend segment must still be `class`.
|
||||||
|
expect(TimetableElementType.schoolClass.pathSegment, 'class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TimetableElementType.label', () {
|
||||||
|
test('provides a German singular label for each type', () {
|
||||||
|
expect(TimetableElementType.student.label, 'Schüler');
|
||||||
|
expect(TimetableElementType.teacher.label, 'Lehrer');
|
||||||
|
expect(TimetableElementType.room.label, 'Raum');
|
||||||
|
expect(TimetableElementType.schoolClass.label, 'Klasse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user